From eb51d4c6696927556ab9ad554d173b2e6beeb8a1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:41:16 +0800 Subject: [PATCH 001/100] fix(acp-snapshot): retain unchanged message ids --- ...table-snapshot-refresh-volatiles.i18n.yaml | 4 +- ...07-27-stable-snapshot-refresh-volatiles.md | 10 +- ...27-stable-snapshot-refresh-volatiles.zh.md | 10 +- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/suite.ts | 112 +++++++++++++-- .../record-suite/rec-child/behavior.json | 6 +- .../record-suite/rec-child/session.1.jsonl | 1 + .../record-suite/rec-child/session.jsonl | 1 + .../support/acp-snapshot/tests/suite.spec.ts | 128 ++++++++++++++++++ 11 files changed, 253 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml index f2d73ddf1f..28e3accc20 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md -2026-07-27-stable-snapshot-refresh-volatiles.md: e2e951cd9f78b319a701a3e60afba48786633f03 -2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 55302b509e28520f90f6cd820e4962be014318cc +2026-07-27-stable-snapshot-refresh-volatiles.md: 5b513ea026008fc0c4ae9a8045408c534c050bec +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: f3c21d14ae235179ca15ec30d964e02877aa86e9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md index e2e951cd9f..5b513ea026 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md @@ -8,9 +8,13 @@ English | [中文](2026-07-27-stable-snapshot-refresh-volatiles.zh.md) ACP snapshot comparison normalizes generated UUIDs, cwd aliases, spill locators, embedded event times, and omitted-byte counts, but refresh write-back persisted the fresh raw values. A behaviorally unchanged refresh therefore rewrote fixtures with new randomness or host-specific path spellings even though the comparison contract considered both logs equal. +Message identity needs a weaker structural precondition than aligned records: an unrelated log event can break record alignment while an inherited message's identity-free value remains unchanged across parent and child logs. Record mode also begins with freshly minted message UUIDs when it replaces an existing fixture. + ## Decision -Refresh write-back uses `normalizeSessionLog` as its sole volatile-value authority. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. +Before record or refresh writes fixtures, the suite fingerprints every complete surface message with its top-level `id` removed and groups occurrences across all parent/child logs. It reuses an existing UUID only when one fingerprint resolves to exactly one fresh ID and one existing ID, then applies that mapping to every fresh log. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. + +Refresh write-back uses `normalizeSessionLog` as its volatile-value authority for aligned leaves. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. Before reuse, the complete logical-record layout must align, apart from the existing packed-chunk and inserted-title equivalences. Normalized-equivalent changed strings form a log-wide bijection: one fresh string maps to exactly one existing string and vice versa, so repeated IDs remain correlated across records. An unexplained record mismatch or conflicting mapping disables normalized string reuse for that log. @@ -26,6 +30,6 @@ Object fields align by key. Array elements align only when all corresponding arr ## Consequences -Repeated refreshes no longer rewrite aligned fixture values solely because the normalizer classifies them as volatile, and new volatile categories added to the normalizer automatically inherit the write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, and strings containing both semantic and volatile changes use fresh values rather than risk reusing misaligned data. +Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, and non-unique message fingerprints use fresh values rather than risk reusing misaligned data. -Focused unit coverage pins recursive object/array behavior, correlated IDs, ambiguous-layout fallback, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. +Focused unit coverage pins scenario-wide parent/child message correlation, unrelated event insertion, record write-back, new/changed/ambiguous messages, recursive object/array behavior, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md index 55302b509e..f3c21d14ae 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -8,9 +8,13 @@ Status: implemented ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别名、spill locator、嵌入的事件时间和省略字节数,但刷新写回会持久化本次生成的原始值。因此,即使比较契约将两份日志视为相等,一次行为未发生变化的刷新仍会用新的随机值或宿主特有的路径写法改写 fixture(测试前置数据)。 +消息身份所需的结构前提比记录对齐更弱:无关的日志事件可能破坏记录对齐,但继承而来的消息去除身份后的值在父级和子级日志之间仍保持不变。录制模式在替换现有 fixture 时也会从新生成的消息 UUID 开始。 + ## 决策 -刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture header 上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 +在录制或刷新写入 fixture 前,套件会移除每条完整 surface 消息的顶层 `id` 并计算指纹,同时将所有父级/子级日志中的出现项分组。仅当一个指纹恰好对应一个本次生成的 ID 和一个现有 ID 时,才会复用现有 UUID,随后将该映射应用到每份本次生成的日志。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。 + +刷新写回以 `normalizeSessionLog` 作为已对齐叶值的易变值判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture header 上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用规范化字符串复用。 @@ -26,6 +30,6 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 后果 -重复刷新不再仅仅因为规范化器将已对齐的 fixture 值归类为易变值,就改写这些值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化,或字符串同时包含语义变化与易变变化时,均使用本次生成的值,避免冒险复用未对齐的数据。 +录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化,或消息指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 -聚焦的单元测试固定了递归处理对象与数组的行为、关联 ID、有歧义布局时的回退、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 +聚焦的单元测试固定了场景范围内的父级/子级消息关联、无关事件插入、录制写回、新增/发生变化/有歧义的消息、递归处理对象与数组的行为、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 363e0f268c..afabe9147a 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md -README.md: 948c33a91977f078d16842c285011bf8f83623bd -README.zh.md: fb86bd4e236be1c79f66dc46fbaac4d7dfbf9977 +README.md: e3752dfb522cd55776f3ef796acdc15037e5a761 +README.zh.md: 6ce531640b5311662e4b958177e4417c8878617f diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 948c33a919..e3752dfb52 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -9,7 +9,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → `{{cwd}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Before record or refresh writes fixtures, an unchanged complete message retains its committed UUID when its identity-free value resolves to exactly one fresh ID and one existing ID across the scenario's parent/child logs; new, changed, and ambiguous messages keep fresh UUIDs. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index fb86bd4e23..6ce531640b 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -9,7 +9,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 - **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名 → `{{cwd}}`,手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 -- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。在录制或刷新写入 fixture 前,如果一条未变化的完整消息去除身份后的值在场景的父级/子级日志中恰好对应一个本次生成的 ID 和一个现有 ID,它就会保留已提交的 UUID;新增、发生变化和有歧义的消息则保留本次生成的 UUID。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 1a8a49dac5..8a99bc0813 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -48,6 +48,9 @@ const TOOLS_TOKEN = '{{tools}}' const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks']) +/** Canonical UUID spelling minted for ordinary message identities. */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + /** A snapshot scenario and how its fixtures are produced. */ export interface Scenario { name: string @@ -480,9 +483,9 @@ export function headerChangeCount(rawLog: string): number { .length } -/** A literal string replacement used to carry an existing fixture's volatile value into a refreshed log. */ +/** A literal string replacement used to carry an existing fixture value into fresh write-back. */ export interface FixtureReplacement { - /** The fresh replay-run value to replace. */ + /** The fresh run's value to replace. */ from: string /** The existing fixture value to keep. */ to: string @@ -494,6 +497,82 @@ function parseJsonlRecords(text: string): Record[] { .map(line => JSON.parse(line) as Record) } +/** Return the complete identified message carried by one surface event. */ +function eventMessage(record: Record): Record | undefined { + const data = record.data + if (!isRecord(data)) return undefined + const message = record.type === 'user/message' + ? data + : record.type === 'assistant/message' || record.type === 'tool/result' || record.type === 'steering/message' + ? data.message + : undefined + if ( + !isRecord(message) + || typeof message.id !== 'string' + || !UUID_RE.test(message.id) + || typeof message.role !== 'string' + || !Array.isArray(message.content) + || !isRecord(message.source) + ) return undefined + return message +} + +/** Serialize parsed JSON by value rather than insertion order. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + if (isRecord(value)) { + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}` + } + return JSON.stringify(value) +} + +/** Index each unambiguous identity-free message value by its sole message id. */ +function uniqueMessageIds(logs: readonly string[]): Map { + const fingerprintsById = new Map() + for (const log of logs) { + for (const record of parseJsonlRecords(log)) { + const message = eventMessage(record) + if (message === undefined) continue + const { id, ...withoutId } = message + const messageId = id as string + const fingerprint = canonicalJson(withoutId) + if (!fingerprintsById.has(messageId)) fingerprintsById.set(messageId, fingerprint) + else if (fingerprintsById.get(messageId) !== fingerprint) fingerprintsById.set(messageId, undefined) + } + } + + const idsByFingerprint = new Map() + for (const [id, fingerprint] of fingerprintsById) { + if (fingerprint === undefined) continue + if (!idsByFingerprint.has(fingerprint)) idsByFingerprint.set(fingerprint, id) + else idsByFingerprint.set(fingerprint, undefined) + } + return idsByFingerprint +} + +/** + * Match unchanged complete messages across a scenario's fresh and existing logs. + * New, changed, repeated, or otherwise ambiguous messages keep their fresh ids. + */ +function fixtureMessageIdReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] { + const freshIds = uniqueMessageIds(logs.map(log => log.content)) + const existingIds = uniqueMessageIds(fixtures) + const replacements: FixtureReplacement[] = [] + for (const [fingerprint, fresh] of freshIds) { + const existing = existingIds.get(fingerprint) + if (fresh === undefined || existing === undefined || fresh === existing) continue + replacements.push({ from: fresh, to: existing }) + } + return replacements +} + +/** Apply literal fixture replacements without changing any other fresh value. */ +function applyFixtureReplacements(content: string, replacements: readonly FixtureReplacement[]): string { + let stable = content + for (const { from, to } of replacements) stable = stable.split(from).join(to) + return stable +} + /** One packed row's member times, or `undefined` for an ordinary record. */ function packedTimes(record: Record): number[] | undefined { if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return undefined @@ -539,14 +618,15 @@ export function unknownToolCallIds(rawLog: string): string[] { } /** - * Build the cross-log id/cwd/spill-path replacements used by refresh write-back. + * Build refresh write-back replacements: scenario-wide unchanged message ids, + * plus per-log session ids, cwd values, and spill paths. * * @param logs The freshly harvested logs, in fixture order. * @param fixtures The existing fixture contents, in matching order. - * @returns Literal replacements from fresh volatile values to the fixture's old values. + * @returns Literal replacements from fresh values to the fixture's existing values. */ export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] { - const replacements: FixtureReplacement[] = [] + const replacements = fixtureMessageIdReplacements(logs, fixtures) for (let i = 0; i < logs.length; i++) { const fresh = parseJsonlRecords((logs[i] as HarvestedLog).content)[0] const existing = parseJsonlRecords(fixtures[i] ?? '')[0] @@ -823,8 +903,7 @@ export function stabilizeRefreshLog( freshContext: NormalizeContext, ): string { const freshRecords = parseJsonlRecords(fresh) - let stable = fresh - for (const { from, to } of replacements) stable = stable.split(from).join(to) + const stable = applyFixtureReplacements(fresh, replacements) const existingRecords = logicalRecords(parseJsonlRecords(existing)) const records = parseJsonlRecords(stable) const existingContext = fixtureContext(existing) @@ -1016,10 +1095,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const portableFixture = scenario.workspaceParent === undefined ? tokenizeSessionFixtureCwd : (log: string): string => log - const existingFixtures = REFRESHING - ? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8'))) - : [] - const replacements = REFRESHING ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) : [] const writesSessionFixtures = (RECORDING && scenario.recorded && scenario.hasModelTurn) || (REFRESHING && comparesLog) if (writesSessionFixtures) { @@ -1032,14 +1107,25 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { 'session.jsonl', ...Array.from({ length: result.sessionLogs.length - 1 }, (_, i) => `session.${i + 1}.jsonl`), ] + const existingFixtures = await Promise.all(outputFixtureFiles.map(async (file) => { + const path = join(dir, file) + return existsSync(path) ? readFile(path, 'utf8') : '' + })) + const replacements = REFRESHING + ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) + : fixtureMessageIdReplacements(result.sessionLogs, existingFixtures) const primary = (result.sessionLogs[0] as HarvestedLog).content await writeFile(join(dir, outputFixtureFiles[0] as string), scrub(portableFixture( - REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) : primary, + REFRESHING + ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) + : applyFixtureReplacements(primary, replacements), ))) for (let i = 1; i < result.sessionLogs.length; i++) { const child = (result.sessionLogs[i] as HarvestedLog).content await writeFile(join(dir, outputFixtureFiles[i] as string), scrub(portableFixture( - REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) : child, + REFRESHING + ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) + : applyFixtureReplacements(child, replacements), ))) } if (RECORDING) { diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json index d98afb4865..971006c139 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json @@ -3,11 +3,13 @@ "logs": [ { "file": "b/parent/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 700, "cwd": "{{CWD}}", "delegationDepth": 0 }, - { "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } + { "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, + { "type": "user/message", "seq": 1, "time": 5, "data": { "role": "user", "content": [{ "type": "text", "text": "same inherited message" }], "source": { "kind": "user" }, "id": "11111111-1111-4111-8111-111111111111" }, "surfaceOp": "append" } ]}, { "file": "b/child/session.jsonl", "lines": [ { "type": "session", "id": "abababab-cdcd-4efe-8ada-badabadabada", "createdAt": 800, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 }, - { "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } + { "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, + { "type": "user/message", "seq": 1, "time": 5, "data": { "role": "user", "content": [{ "type": "text", "text": "same inherited message" }], "source": { "kind": "user" }, "id": "11111111-1111-4111-8111-111111111111" }, "surfaceOp": "append" } ]} ] } diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl index 4fa81014ae..384b3954cf 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl @@ -1,2 +1,3 @@ {"type":"session","id":"abababab-cdcd-4efe-8ada-badabadabada","createdAt":800,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","parentSession":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","delegationDepth":1} {"type":"request/header","seq":0,"time":2,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"user/message","seq":1,"time":5,"data":{"role":"user","content":[{"type":"text","text":"same inherited message"}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"} diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl index e972a78d8e..0ffe7f9f5f 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl @@ -1,2 +1,3 @@ {"type":"session","id":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","createdAt":700,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","delegationDepth":0} {"type":"request/header","seq":0,"time":3,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"user/message","seq":1,"time":5,"data":{"role":"user","content":[{"type":"text","text":"same inherited message"}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"} diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index da069905ec..2cd665f540 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -193,6 +193,18 @@ describe('defineAcpSnapshotSuite: record inventory write-back', () => { expect(fixture).toContain('"cwd":"{{cwd}}"') expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow() }) + + it('retains an unchanged message id across the recorded parent and child fixtures', () => { + const existingMessageId = '22222222-2222-4222-8222-222222222222' + const freshMessageId = '11111111-1111-4111-8111-111111111111' + const fixtures = ['session.jsonl', 'session.1.jsonl'] + .map(file => readFileSync(join(recordDir, 'rec-child', file), 'utf8')) + + for (const fixture of fixtures) { + expect(fixture).toContain(`"id":"${existingMessageId}"`) + expect(fixture).not.toContain(freshMessageId) + } + }) }) describe('defineAcpSnapshotSuite: registration contract', () => { @@ -663,6 +675,78 @@ describe('refreshFixtureReplacements', () => { { from: freshBash, to: oldBash }, ]) }) + + it('maps one inherited message id across parent and child logs', () => { + const freshMessageId = '11111111-1111-4111-8111-111111111111' + const existingMessageId = '22222222-2222-4222-8222-222222222222' + const content = [{ type: 'text', text: 'inherited' }] + const log = (sessionId: string, messageId: string): string => [ + JSON.stringify({ type: 'session', id: sessionId, cwd: '/same' }), + JSON.stringify({ + type: 'user/message', + data: { role: 'user', content, source: { kind: 'user' }, id: messageId }, + }), + '', + ].join('\n') + const harvested = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content }) + + const replacements = refreshFixtureReplacements( + [harvested(log('fresh-parent', freshMessageId)), harvested(log('fresh-child', freshMessageId))], + [log('old-parent', existingMessageId), log('old-child', existingMessageId)], + ) + + expect(replacements.filter(replacement => replacement.from === freshMessageId)).toEqual([ + { from: freshMessageId, to: existingMessageId }, + ]) + }) + + it('keeps fresh ids for new, changed, and ambiguous messages', () => { + const ids = { + new: '11111111-1111-4111-8111-111111111111', + changed: '22222222-2222-4222-8222-222222222222', + ambiguousA: '33333333-3333-4333-8333-333333333333', + ambiguousB: '44444444-4444-4444-8444-444444444444', + oldChanged: '55555555-5555-4555-8555-555555555555', + oldAmbiguous: '66666666-6666-4666-8666-666666666666', + stable: '77777777-7777-4777-8777-777777777777', + } as const + const message = (id: string, text: string): Record => ({ + type: 'user/message', + data: { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' }, id }, + }) + const log = (messages: Record[]): string => [ + JSON.stringify({ type: 'session', id: 'same', cwd: '/same' }), + ...messages.map(record => JSON.stringify(record)), + '', + ].join('\n') + const fresh = log([ + message(ids.new, 'new'), + message(ids.changed, 'changed'), + message(ids.changed, 'changed again'), + message(ids.ambiguousA, 'duplicate'), + message(ids.ambiguousB, 'duplicate'), + message(ids.stable, 'stable'), + ]) + const existing = log([ + message(ids.oldChanged, 'before'), + message(ids.oldAmbiguous, 'duplicate'), + message(ids.stable, 'stable'), + ]) + + const replacements = refreshFixtureReplacements( + [{ id: 'diagnostic', createdAt: 1, content: fresh }], + [existing], + ) + + const replacedIds = replacements.map(replacement => replacement.from) + for (const id of [ + ids.new, + ids.changed, + ids.ambiguousA, + ids.ambiguousB, + ids.stable, + ]) expect(replacedIds).not.toContain(id) + }) }) describe('stabilizeRefreshLog', () => { @@ -765,6 +849,50 @@ describe('stabilizeRefreshLog', () => { ].join('\n')) }) + it('retains unchanged message ids across an unrelated inserted event', () => { + const freshUserId = '11111111-1111-4111-8111-111111111111' + const existingUserId = '22222222-2222-4222-8222-222222222222' + const freshAssistantId = '33333333-3333-4333-8333-333333333333' + const existingAssistantId = '44444444-4444-4444-8444-444444444444' + const user = (id: string): Record => ({ + type: 'user/message', + data: { role: 'user', content: [{ type: 'text', text: 'same user' }], source: { kind: 'user' }, id }, + }) + const assistant = (id: string): Record => ({ + type: 'assistant/message', + data: { + turn: 1, + step: 1, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'same assistant' }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + id, + }, + }, + }) + const lines = (records: Record[]): string => [ + JSON.stringify({ type: 'session', id: 'same', createdAt: 1, cwd: '/same' }), + ...records.map(record => JSON.stringify(record)), + '', + ].join('\n') + const fresh = lines([ + user(freshUserId), + { type: 'session/inherited', data: {} }, + assistant(freshAssistantId), + ]) + const existing = lines([user(existingUserId), assistant(existingAssistantId)]) + const replacements = refreshFixtureReplacements( + [{ id: 'diagnostic', createdAt: 1, content: fresh }], + [existing], + ) + const output = stabilize(fresh, existing, replacements).trim().split('\n') + .map(line => JSON.parse(line) as Record) + + expect((output[1]?.data as { id: string }).id).toBe(existingUserId) + expect(((output[3]?.data as { message: { id: string } }).message).id).toBe(existingAssistantId) + }) + it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => { const fresh = [ '{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}', From 466a2f12c3196c532ea6fde0918805daf67d87a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:51:07 +0800 Subject: [PATCH 002/100] fix(snapshot): stabilize all recorder message ids --- ...table-snapshot-refresh-volatiles.i18n.yaml | 4 +- ...07-27-stable-snapshot-refresh-volatiles.md | 4 +- ...27-stable-snapshot-refresh-volatiles.zh.md | 4 +- apps/web/tests/scaffold.ts | 9 ++-- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 15 ++++-- examples/tui-agent/tests/tui.snapshot.ts | 25 +++++----- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 4 +- packages/support/acp-snapshot/README.zh.md | 4 +- packages/support/acp-snapshot/src/index.ts | 1 + packages/support/acp-snapshot/src/suite.ts | 49 ++++++++++++------- .../support/acp-snapshot/tests/suite.spec.ts | 37 +++++++++++++- 12 files changed, 110 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml index 28e3accc20..3a474e377b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md -2026-07-27-stable-snapshot-refresh-volatiles.md: 5b513ea026008fc0c4ae9a8045408c534c050bec -2026-07-27-stable-snapshot-refresh-volatiles.zh.md: f3c21d14ae235179ca15ec30d964e02877aa86e9 +2026-07-27-stable-snapshot-refresh-volatiles.md: cd806c929ba956098f532d19159ff2dc3e782325 +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 3144bcb45aa8524e29fae3d07479cb917987ea79 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md index 5b513ea026..cd806c929b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md @@ -12,7 +12,7 @@ Message identity needs a weaker structural precondition than aligned records: an ## Decision -Before record or refresh writes fixtures, the suite fingerprints every complete surface message with its top-level `id` removed and groups occurrences across all parent/child logs. It reuses an existing UUID only when one fingerprint resolves to exactly one fresh ID and one existing ID, then applies that mapping to every fresh log. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. +Before record or refresh writes session fixtures, the shared snapshot support fingerprints every complete surface message with its top-level `id` removed and groups occurrences across all parent/child logs. It reuses an existing UUID only when one fingerprint resolves to exactly one fresh ID and one existing ID, then applies that mapping to every fresh log. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. ACP, JSON-RPC, TUI, and web recorders pass fixture-ready logs through the same helper before writing. Refresh write-back uses `normalizeSessionLog` as its volatile-value authority for aligned leaves. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. @@ -30,6 +30,6 @@ Object fields align by key. Array elements align only when all corresponding arr ## Consequences -Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, and non-unique message fingerprints use fresh values rather than risk reusing misaligned data. +Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout, regardless of whether ACP, JSON-RPC, TUI, or web owns the recording. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, and non-unique message fingerprints use fresh values rather than risk reusing misaligned data. Focused unit coverage pins scenario-wide parent/child message correlation, unrelated event insertion, record write-back, new/changed/ambiguous messages, recursive object/array behavior, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md index f3c21d14ae..3144bcb45a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -12,7 +12,7 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 决策 -在录制或刷新写入 fixture 前,套件会移除每条完整 surface 消息的顶层 `id` 并计算指纹,同时将所有父级/子级日志中的出现项分组。仅当一个指纹恰好对应一个本次生成的 ID 和一个现有 ID 时,才会复用现有 UUID,随后将该映射应用到每份本次生成的日志。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。 +在录制或刷新写入会话 fixture 前,共享快照支持层会移除每条完整 surface 消息的顶层 `id` 并计算指纹,同时将所有父级/子级日志中的出现项分组。仅当一个指纹恰好对应一个本次生成的 ID 和一个现有 ID 时,才会复用现有 UUID,随后将该映射应用到每份本次生成的日志。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。ACP、JSON-RPC、TUI 和 web 录制器都会先让可写入 fixture 的日志经过同一个辅助函数,再执行写入。 刷新写回以 `normalizeSessionLog` 作为已对齐叶值的易变值判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture header 上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 @@ -30,6 +30,6 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 后果 -录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化,或消息指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 +录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID,无论该录制由 ACP、JSON-RPC、TUI 还是 web 负责。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化,或消息指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 聚焦的单元测试固定了场景范围内的父级/子级消息关联、无关事件插入、录制写回、新增/发生变化/有歧义的消息、递归处理对象与数组的行为、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 753cdc2953..d11604c075 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -27,7 +27,7 @@ import { expect } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' -import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' +import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot' import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' @@ -302,11 +302,14 @@ function rawSessionLog(session: Session): string { export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise { const agent = scaffold.ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`) - const tokenized = scrubRequestHeaders(rawSessionLog(agent.session)) + const fresh = scrubRequestHeaders(rawSessionLog(agent.session)) .split(sessionId).join('{{sessionId}}') .split(scaffold.workspaceCwd).join('{{cwd}}') .replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"') - await writeFile(fixturePath, tokenized) + const existing = existsSync(fixturePath) ? await readFile(fixturePath, 'utf8') : '' + const stable = stabilizeFixtureMessageIds([fresh], [existing])[0] + if (stable === undefined) throw new Error('record harvest: no stabilized fixture') + await writeFile(fixturePath, stable) } /** diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index c54812e3e5..3f0edac743 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -9,6 +9,7 @@ * fixtures and rewrites expected outputs. */ +import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' @@ -19,6 +20,7 @@ import { normalizeStdout, refreshFixtureReplacements, scrubRequestHeaders, + stabilizeFixtureMessageIds, stabilizeRefreshLog, tokenizeSessionFixtureCwd, type HarvestedLog, @@ -230,20 +232,25 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const { result, notifications, logs, cwd } = await runScenario(scenario) const ordered = orderLogs(logs, scenario) const actualContext = contextOf(ordered, cwd) + const files = fixtureFiles(scenario) if (recording) { // Fixtures carry tokenized request headers; llm-replay reads only // assistant output and tool traffic, so scrubbing keeps prompts and // schemas out of the corpus without affecting replay. await mkdir(scenarioDir, { recursive: true }) - await Promise.all(ordered.map(async (log, index) => { - const file = fixtureFiles(scenario)[index] + const existing = await Promise.all(files.map(async file => existsSync(file) ? readFile(file, 'utf8') : '')) + const fixtures = stabilizeFixtureMessageIds( + ordered.map(log => scrubRequestHeaders(tokenizeSessionFixtureCwd(log.content))), + existing, + ) + await Promise.all(fixtures.map(async (fixture, index) => { + const file = files[index] if (file === undefined) throw new Error(`no fixture path for persisted log ${index}`) - await writeFile(file, scrubRequestHeaders(tokenizeSessionFixtureCwd(log.content))) + await writeFile(file, fixture) })) } - const files = fixtureFiles(scenario) let expectedContents = await Promise.all(files.map(file => readFile(file, 'utf8'))) if (refreshing) { diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 95b960e3b6..934621b037 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -1,10 +1,15 @@ +import { existsSync } from 'node:fs' import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot' +import { + scrubRequestHeaders, + stabilizeFixtureMessageIds, + tokenizeSessionFixtureCwd, +} from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -484,17 +489,15 @@ async function runScenario(scenario: Scenario): Promise { async function writeRecording(scenario: Scenario, result: ScenarioResult): Promise { const dir = scenarioDir(scenario) await mkdir(dir, { recursive: true }) - await writeFile( - join(dir, 'session.jsonl'), - scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(result.parent))), - ) expect(result.children).toHaveLength(scenario.childSessions ?? 0) - for (const [index, child] of result.children.entries()) { - await writeFile( - join(dir, `session.${index + 1}.jsonl`), - scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(child))), - ) - } + const files = [join(dir, 'session.jsonl'), ...childFixturePaths(scenario)] + const existing = await Promise.all(files.map(async file => existsSync(file) ? readFile(file, 'utf8') : '')) + const fixtures = stabilizeFixtureMessageIds( + [result.parent, ...result.children] + .map(session => scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(session)))), + existing, + ) + await Promise.all(fixtures.map((fixture, index) => writeFile(files[index] as string, fixture))) } describe('TUI recorded-session terminal snapshots', () => { diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index afabe9147a..f90ce7ada8 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md -README.md: e3752dfb522cd55776f3ef796acdc15037e5a761 -README.zh.md: 6ce531640b5311662e4b958177e4417c8878617f +README.md: c5a1a07b9f85e1a91c52fe102be17e4172be112d +README.zh.md: 2454df9b3d8e67b4728d6582279ba21798d2ba9e diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index e3752dfb52..c5a1a07b9f 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -8,7 +8,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. -- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → `{{cwd}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → `{{cwd}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)), and `stabilizeFixtureMessageIds` (committed UUIDs carried into unchanged, unambiguous messages across any recorder's fixture-ready parent/child logs). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Before record or refresh writes fixtures, an unchanged complete message retains its committed UUID when its identity-free value resolves to exactly one fresh ID and one existing ID across the scenario's parent/child logs; new, changed, and ambiguous messages keep fresh UUIDs. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. @@ -59,7 +59,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. +Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the JSON-RPC, TUI, and web snapshot recorders. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. ## Model Experience diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 6ce531640b..2454df9b3d 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -8,7 +8,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 -- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名 → `{{cwd}}`,手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名 → `{{cwd}}`,手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)、`scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))和 `stabilizeFixtureMessageIds`(针对任意录制器已准备写入 fixture 的父级/子级日志,将已提交 UUID 带入未变化且无歧义的消息)。 - **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。在录制或刷新写入 fixture 前,如果一条未变化的完整消息去除身份后的值在场景的父级/子级日志中恰好对应一个本次生成的 ID 和一个现有 ID,它就会保留已提交的 UUID;新增、发生变化和有歧义的消息则保留本次生成的 UUID。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 @@ -59,7 +59,7 @@ defineAcpSnapshotSuite({ 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 -约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 +约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 JSON-RPC、TUI 和 web 快照录制器消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 ## 模型体验 diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index 09d05031c7..6d8f5c0953 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -46,6 +46,7 @@ export { export { defineAcpSnapshotSuite, refreshFixtureReplacements, + stabilizeFixtureMessageIds, stabilizeRefreshLog, type Scenario, type SnapshotSuiteOptions, diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 8a99bc0813..315095cc10 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -554,8 +554,8 @@ function uniqueMessageIds(logs: readonly string[]): Map log.content)) +function fixtureMessageIdReplacements(logs: readonly string[], fixtures: readonly string[]): FixtureReplacement[] { + const freshIds = uniqueMessageIds(logs) const existingIds = uniqueMessageIds(fixtures) const replacements: FixtureReplacement[] = [] for (const [fingerprint, fresh] of freshIds) { @@ -573,6 +573,18 @@ function applyFixtureReplacements(content: string, replacements: readonly Fixtur return stable } +/** + * Carry committed UUIDs into unchanged, unambiguous messages in fresh session fixtures. + * + * @param logs Fresh fixture-ready session JSONL contents for one scenario. + * @param fixtures Existing fixture contents in matching order; missing fixtures may be empty strings. + * @returns The fresh contents with only reusable message UUIDs replaced. + */ +export function stabilizeFixtureMessageIds(logs: readonly string[], fixtures: readonly string[]): string[] { + const replacements = fixtureMessageIdReplacements(logs, fixtures) + return logs.map(log => applyFixtureReplacements(log, replacements)) +} + /** One packed row's member times, or `undefined` for an ordinary record. */ function packedTimes(record: Record): number[] | undefined { if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return undefined @@ -626,7 +638,7 @@ export function unknownToolCallIds(rawLog: string): string[] { * @returns Literal replacements from fresh values to the fixture's existing values. */ export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] { - const replacements = fixtureMessageIdReplacements(logs, fixtures) + const replacements = fixtureMessageIdReplacements(logs.map(log => log.content), fixtures) for (let i = 0; i < logs.length; i++) { const fresh = parseJsonlRecords((logs[i] as HarvestedLog).content)[0] const existing = parseJsonlRecords(fixtures[i] ?? '')[0] @@ -1111,23 +1123,22 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const path = join(dir, file) return existsSync(path) ? readFile(path, 'utf8') : '' })) - const replacements = REFRESHING + const refreshReplacements = REFRESHING ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) - : fixtureMessageIdReplacements(result.sessionLogs, existingFixtures) - const primary = (result.sessionLogs[0] as HarvestedLog).content - await writeFile(join(dir, outputFixtureFiles[0] as string), scrub(portableFixture( - REFRESHING - ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) - : applyFixtureReplacements(primary, replacements), - ))) - for (let i = 1; i < result.sessionLogs.length; i++) { - const child = (result.sessionLogs[i] as HarvestedLog).content - await writeFile(join(dir, outputFixtureFiles[i] as string), scrub(portableFixture( - REFRESHING - ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) - : applyFixtureReplacements(child, replacements), - ))) - } + : [] + const outputFixtures = REFRESHING + ? result.sessionLogs.map((log, index) => scrub(portableFixture(stabilizeRefreshLog( + log.content, + existingFixtures[index] as string, + refreshReplacements, + ctx, + )))) + : stabilizeFixtureMessageIds( + result.sessionLogs.map(log => scrub(portableFixture(log.content))), + existingFixtures, + ) + await Promise.all(outputFixtures.map((fixture, index) => + writeFile(join(dir, outputFixtureFiles[index] as string), fixture))) if (RECORDING) { const outputNames = new Set(outputFixtureFiles) const entries = await readdir(dir, { withFileTypes: true }) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 2cd665f540..e2e4129a5b 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -4,7 +4,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' -import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts' +import { + defineAcpSnapshotSuite, + stabilizeFixtureMessageIds, + type HarvestedLog, + type Scenario, +} from '../src/index.ts' import { assertUniqueSnapshotContents, claimSharedSnapshot, @@ -635,6 +640,36 @@ describe('unknownToolCallIds', () => { }) }) +describe('stabilizeFixtureMessageIds', () => { + it('reuses one committed message UUID across fixture-ready parent and child logs', () => { + const freshId = '11111111-1111-4111-8111-111111111111' + const existingId = '22222222-2222-4222-8222-222222222222' + const log = (session: string, id: string): string => [ + JSON.stringify({ type: 'session', id: session, cwd: '{{cwd}}' }), + JSON.stringify({ + type: 'user/message', + data: { role: 'user', content: [{ type: 'text', text: 'same' }], source: { kind: 'user' }, id }, + }), + '', + ].join('\n') + const fresh = [log('fresh-parent', freshId), log('fresh-child', freshId)] + const existing = [log('old-parent', existingId), log('old-child', existingId)] + + const stable = stabilizeFixtureMessageIds(fresh, existing) + + expect(stable).toHaveLength(2) + for (const fixture of stable) { + expect(fixture).toContain(`"id":"${existingId}"`) + expect(fixture).not.toContain(freshId) + } + }) + + it('leaves fresh fixtures unchanged when no committed counterpart exists', () => { + const fresh = '{"type":"session","id":"new"}\n' + expect(stabilizeFixtureMessageIds([fresh], [''])).toEqual([fresh]) + }) +}) + describe('refreshFixtureReplacements', () => { it('maps fresh ids and cwd values to the existing fixture values, skipping non-replacements', () => { const log = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content }) From 8d92a9bdaaebf79f45027a48ada60212416b20dc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:43:44 +0800 Subject: [PATCH 003/100] fix(user-interaction): reject ask_user_question from delegated subagents --- ...-ask-user-delegated-caller-guard.i18n.yaml | 6 ++++ ...6-08-01-ask-user-delegated-caller-guard.md | 29 +++++++++++++++ ...8-01-ask-user-delegated-caller-guard.zh.md | 29 +++++++++++++++ packages/ui/tool-ask-user/README.i18n.yaml | 4 +-- packages/ui/tool-ask-user/README.md | 1 + packages/ui/tool-ask-user/README.zh.md | 1 + .../tool-ask-user/tests/tool-ask-user.spec.ts | 33 ++++++++++++++++- packages/ui/user-interaction/README.i18n.yaml | 4 +-- packages/ui/user-interaction/README.md | 4 +-- packages/ui/user-interaction/README.zh.md | 4 +-- packages/ui/user-interaction/src/index.ts | 12 +++++++ .../tests/user-interaction.spec.ts | 35 +++++++++++++++++++ 12 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml new file mode 100644 index 0000000000..800a575f3b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md +2026-08-01-ask-user-delegated-caller-guard.md: 17c5e42a1d12c018507c6cf410129bb17099e967 +2026-08-01-ask-user-delegated-caller-guard.zh.md: 38f059ba87ac5208cca6cb94ba9ee5223a6987b0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md new file mode 100644 index 0000000000..17c5e42a1d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md @@ -0,0 +1,29 @@ +# Agent Note: Reject ask_user_question from delegated subagents + +Status: implemented + +English | [中文](2026-08-01-ask-user-delegated-caller-guard.zh.md) + +## Problem + +A delegated subagent that calls the `ask_user_question` tool blocks indefinitely. The tool pauses for a human answer, but a child context has no human answerer, so no answer ever arrives and the subagent run hangs until it is cancelled externally. + +## Decision + +`UserInteractionService.ask()` rejects any request whose calling agent is a delegated subagent — `request.agent.session.header.delegationDepth > 0` — with a new `UserInteractionError` code `DELEGATED_CALLER` and the message `ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`. The check runs at the top of `ask()`, after the aborted/empty guards and before intent validation, so no provider interaction happens for a rejected child. This mirrors the goal tools' top-level-only authority (`create_goal` rejects non-top-level agents with a direct-human-turn requirement). + +## Alternatives considered + +**Leave the child blocked until the parent forwards an answer.** Rejected: no answerer exists in the child context and no forwarding seam exists; the observed behavior is a permanent hang. + +**Reject inside the tool (`dsh-tool-ask-user`) instead of the service.** Rejected: that consumer seam is bypassed by direct callers of `ctx.userInteraction.ask()`; the operation boundary that owns the decision is the service itself. + +**Warn children off via the model-facing description.** Rejected: the rejection is already a loud, self-explanatory error, and a description edit would not stop the hang for a model that calls anyway. + +## Consequences + +Delegated subagent calls fail fast with a stable error instead of hanging; a child that needs a decision must delegate the question to the top-level agent. Programmatic askers without an agent and top-level agents (`delegationDepth` absent or 0) are unaffected and still reach the provider. The `DELEGATED_CALLER` code joins the documented `UserInteractionError` taxonomy in the package READMEs, and the model-facing description is unchanged. + +## Testing + +Two new unit tests exercise the guard: `user-interaction.spec.ts` asserts that `ask()` rejects with `DELEGATED_CALLER` and never calls the provider for a session created with `{ meta: { delegationDepth: 1 } }`, plus a positive control at `delegationDepth: 0`; `tool-ask-user.spec.ts` asserts that a tool call from a delegated agent surfaces the structured error and never reaches the provider. Both packages pass, as does the parent `packages/ui` scope, and the two touched `src` files hold 100% per-file coverage. diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md new file mode 100644 index 0000000000..38f059ba87 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 拒绝委托子代理调用 ask_user_question + +Status: implemented + +[English](2026-08-01-ask-user-delegated-caller-guard.md) | 中文 + +## 问题 + +委托子代理调用 `ask_user_question` 工具时会无限阻塞。该工具会暂停等待人类回答,但子代理上下文中没有人类应答者,因此永远等不到回答,子代理运行只能被外部取消。 + +## 决策 + +`UserInteractionService.ask()` 拒绝任何调用方为委托子代理的请求 —— `request.agent.session.header.delegationDepth > 0` —— 抛出新的 `UserInteractionError`,代码为 `DELEGATED_CALLER`,消息为 `ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`。该检查位于 `ask()` 开头,在已中止/空问题守卫之后、意图校验之前,因此被拒绝的子代理不会触发任何提供方交互。这与 goal 工具仅限顶层代理的权限保持一致(`create_goal` 以直接人工回合要求拒绝非顶层代理)。 + +## 备选方案 + +**让子代理一直阻塞,直到父代理转发回答。** 不予采用:子代理上下文中不存在应答者,也没有任何转发 seam;实际观察到的行为就是永久挂起。 + +**在工具(`dsh-tool-ask-user`)而非服务中拒绝。** 不予采用:直接调用 `ctx.userInteraction.ask()` 的调用方会绕过该消费方 seam;拥有此决策权的操作边界是服务本身。 + +**通过模型侧描述来警告子代理。** 不予采用:拒绝本身已是响亮且自解释的错误,而且修改描述并不能阻止仍然去调用的模型造成挂起。 + +## 影响 + +委托子代理的调用会以稳定错误快速失败,而不是挂起;需要决策的子代理必须把问题转交给顶层代理。不带 agent 的程序化调用方以及顶层代理(`delegationDepth` 缺省或为 0)不受影响,仍会到达提供方。`DELEGATED_CALLER` 代码已加入包 README 中记载的 `UserInteractionError` 分类,模型侧描述保持不变。 + +## Testing + +两个新的单元测试覆盖该守卫:`user-interaction.spec.ts` 断言以 `{ meta: { delegationDepth: 1 } }` 创建的会话调用 `ask()` 会以 `DELEGATED_CALLER` 拒绝且绝不调用提供方,并补充了 `delegationDepth: 0` 的正向对照;`tool-ask-user.spec.ts` 断言委托子代理发出的工具调用会呈现结构化错误且绝不触达提供方。两个包均通过,父级 `packages/ui` 作用域也通过,且两个被改动的 `src` 文件保持 100% 逐文件覆盖率。 diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml index 7b8e667b58..869a5988c1 100644 --- a/packages/ui/tool-ask-user/README.i18n.yaml +++ b/packages/ui/tool-ask-user/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tool-ask-user/README.md -README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d -README.zh.md: acaffec0764404a0e0e842ffc2b4efdee8869c4f +README.md: d7866ff018ebfed5afbf105b1a20714490bdb818 +README.zh.md: 18a1c8e9f958c174fc34f26a572d88b6c031d7f9 diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 8e779f4025..d7866ff018 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -54,4 +54,5 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **A pending question blocks the tool call until the human answers** — the tool declares no `timeout-policy` budget; cancellation rides the turn's `exec.signal` only. +- **Delegated subagents cannot ask the user** — `ask_user_question` rejects calls from a delegated subagent with `DELEGATED_CALLER`; a child that needs a decision must delegate the question to the top-level agent. - **Native answers render as JSON text** — the canonical value remains structured, but the model-facing result uses compact JSON rather than a richer content-block vocabulary. diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md index acaffec076..18a1c8e9f9 100644 --- a/packages/ui/tool-ask-user/README.zh.md +++ b/packages/ui/tool-ask-user/README.zh.md @@ -54,4 +54,5 @@ ## 已知限制与暂缓事项 - **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。 +- **委托的子代理不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝来自委托子代理的调用;需要决策的子代理必须把问题转交给顶层代理。 - **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。 diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 395986aed1..4c9e572c40 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction' @@ -201,6 +202,7 @@ describe('ask_user_question tool', () => { it('passes optional header and agent through to the user-interaction request', async () => { const ctx = await setup() + await ctx.plugin(SessionStore) const seen: AskUserQuestionRequest[] = [] ctx.userInteraction.registerProvider({ async ask(request) { @@ -208,7 +210,8 @@ describe('ask_user_question tool', () => { return { answers: [{ id: 'continue', selected: ['ok'] }] } }, }) - const agent = { id: 'main' } as unknown as Agent + const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 0 } }) + const agent = { session } as unknown as Agent const result = await ctx.tools.execute({ signal: testToolSignal, @@ -238,6 +241,34 @@ describe('ask_user_question tool', () => { }) }) + it('rejects a delegated subagent with a structured DELEGATED_CALLER error', async () => { + const ctx = await setup() + await ctx.plugin(SessionStore) + const seen: AskUserQuestionRequest[] = [] + ctx.userInteraction.registerProvider({ + async ask(request) { + seen.push(request) + return { answers: [{ id: 'continue', selected: ['ok'] }] } + }, + }) + const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 1 } }) + const agent = { session } as unknown as Agent + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('ask-delegated'), + name: 'ask_user_question', + arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, + agent, + }) + + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'UserInteractionError', code: 'DELEGATED_CALLER' } }, + }) + expect(seen).toHaveLength(0) + }) + it('returns a structured error for empty question batches', async () => { const ctx = await setup() diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index a74e6ec71b..7d2ea23b14 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md -README.md: d62e75d110b8be339c5f9449b0834320f695ac99 -README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816 +README.md: 3459f915f2cd94d4083975440731661d8aeb9108 +README.zh.md: 26d40e98dbcc15ef18a85cd98205defb765d4469 diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d62e75d110..3459f915f2 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -18,7 +18,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod - `AskUserQuestionIntent` — `{ kind: 'plan-review', approve }`; the tagged presentation intent below. - `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`. - `UserInteractionProvider` — UI implementation with `ask(request)`. -- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. +- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, and `DELEGATED_CALLER`. When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. @@ -32,7 +32,7 @@ This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh- ## Model Experience -Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: no user-interaction provider is registered`, or `Error: `. Waiting for the human adds no tokens. +Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`, `Error: no user-interaction provider is registered`, or `Error: `. Waiting for the human adds no tokens. #### KV Cache effect diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index 55258e85e5..26d40e98db 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -18,7 +18,7 @@ - `AskUserQuestionIntent`:`{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。 - `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。 - `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。 -- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 +- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER`、`ASK_ABORTED` 和 `DELEGATED_CALLER` 等代码。 当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 @@ -32,7 +32,7 @@ ## 模型体验 -间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: no user-interaction provider is registered` 或 `Error: `。等待人类回答不会增加 token。 +间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`、`Error: no user-interaction provider is registered` 或 `Error: `。等待人类回答不会增加 token。 #### KV Cache 影响 diff --git a/packages/ui/user-interaction/src/index.ts b/packages/ui/user-interaction/src/index.ts index 506b3c6bfe..b7e76c1d47 100644 --- a/packages/ui/user-interaction/src/index.ts +++ b/packages/ui/user-interaction/src/index.ts @@ -77,8 +77,15 @@ export class UserInteractionService extends Service { /** * Ask the active UI provider and wait for the user's answer. * + * Human-interaction requests are only valid from a top-level agent: a + * delegated subagent has no human answerer in its own context, so asking + * there would block forever. This mirrors the goal tools' top-level-only + * authority (`create_goal` rejects non-top-level agents). + * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. + * @throws {UserInteractionError} code `DELEGATED_CALLER` when the calling + * agent is a delegated subagent (`session.header.delegationDepth > 0`). */ async ask(request: AskUserQuestionRequest): Promise { if (request.signal?.aborted) { @@ -87,6 +94,11 @@ export class UserInteractionService extends Service { if (request.questions.length === 0) { throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS') } + if ((request.agent?.session.header.delegationDepth ?? 0) > 0) { + throw new UserInteractionError( + 'ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent', + 'DELEGATED_CALLER') + } // A presentation intent asserts two things the types cannot: that the // named approve label is one of this question's own options, and that a // plan-review carries the plan it is a review of. A UI honouring the diff --git a/packages/ui/user-interaction/tests/user-interaction.spec.ts b/packages/ui/user-interaction/tests/user-interaction.spec.ts index df6b878cbd..30fdf49a7e 100644 --- a/packages/ui/user-interaction/tests/user-interaction.spec.ts +++ b/packages/ui/user-interaction/tests/user-interaction.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' import UserInteractionService, { UserInteractionError, type AskUserQuestionRequest, @@ -84,6 +86,39 @@ describe('UserInteractionService', () => { expect(p.ask).not.toHaveBeenCalled() }) + it('rejects a delegated subagent before reaching the provider', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + const p = { ask: vi.fn(async () => ({ answers: [] })) } + ctx.userInteraction.registerProvider(p) + const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 1 } }) + const agent = { session } as unknown as Agent + + await expect(ctx.userInteraction.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + agent, + })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'DELEGATED_CALLER' }) + expect(p.ask).not.toHaveBeenCalled() + }) + + it('still reaches the provider for a top-level agent (delegationDepth 0)', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + const p = provider('yes') + ctx.userInteraction.registerProvider(p) + const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 0 } }) + const agent = { session } as unknown as Agent + + const result = await ctx.userInteraction.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + agent, + }) + + expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] }) + }) + it('rejects an intent whose approve label names none of its own options', async () => { const ctx = new Context() await ctx.plugin(UserInteractionService) From 46d8d97efec7aec57ac3a280a8b5956c0b13ccf9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:45:33 +0800 Subject: [PATCH 004/100] chore(cordis): regenerate service catalog for user-interaction JSDoc --- docs/cordis-catalog/services.md | 7 +++++++ packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22bc6343ea..dee9c09f38 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2445,8 +2445,15 @@ registerProvider(provider: UserInteractionProvider): () => void /** * Ask the active UI provider and wait for the user's answer. * + * Human-interaction requests are only valid from a top-level agent: a + * delegated subagent has no human answerer in its own context, so asking + * there would block forever. This mirrors the goal tools' top-level-only + * authority (`create_goal` rejects non-top-level agents). + * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. + * @throws {UserInteractionError} code `DELEGATED_CALLER` when the calling + * agent is a delegated subagent (`session.header.delegationDepth > 0`). */ async ask(request: AskUserQuestionRequest): Promise ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2beb8a3c77..7089ee0f5d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1114,7 +1114,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async ask(request: AskUserQuestionRequest): Promise', - jsDoc: '/**\n * Ask the active UI provider and wait for the user\'s answer.\n *\n * @param request Questions, owner agent, and abort signal.\n * @returns The answer chosen or typed by the human.\n */', + jsDoc: '/**\n * Ask the active UI provider and wait for the user\'s answer.\n *\n * Human-interaction requests are only valid from a top-level agent: a\n * delegated subagent has no human answerer in its own context, so asking\n * there would block forever. This mirrors the goal tools\' top-level-only\n * authority (`create_goal` rejects non-top-level agents).\n *\n * @param request Questions, owner agent, and abort signal.\n * @returns The answer chosen or typed by the human.\n * @throws {UserInteractionError} code `DELEGATED_CALLER` when the calling\n * agent is a delegated subagent (`session.header.delegationDepth > 0`).\n */', }, ], }, From 598f9719f4e27b6d5e37478fb20a85214956fd0d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 10:23:26 +0800 Subject: [PATCH 005/100] refactor(landlock-run): unify workspace release (review round 1) --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 12 +- .../feature/2026-07-06-sandbox.zh.md | 12 +- ...6-in-repository-landlock-release.i18n.yaml | 6 + ...26-08-06-in-repository-landlock-release.md | 42 +++ ...08-06-in-repository-landlock-release.zh.md | 42 +++ .github/workflows/landlock-run-release.yml | 170 +++++++++ .github/workflows/landlock-run.yml | 42 ++- .github/workflows/sandbox.yml | 27 +- THIRD_PARTY_NOTICES.md | 4 +- knip.json | 1 + native/README.i18n.yaml | 4 +- native/README.md | 10 +- native/README.zh.md | 10 +- native/landlock-run/AGENTS.md | 2 +- native/landlock-run/docs/release.md | 36 +- native/landlock-run/pnpm-lock.yaml | 345 ------------------ native/landlock-run/pnpm-workspace.yaml | 8 - native/landlock-run/scripts/bump-release.mjs | 11 +- .../landlock-run/scripts/commit-release.mjs | 10 +- native/landlock-run/scripts/repo.mjs | 4 +- .../landlock-run/scripts/verify-release.mjs | 16 +- package.json | 2 + packages/bash/bash-sandbox/package.json | 2 +- packages/bash/bash-sandbox/tsconfig.json | 3 + .../examples/agent-spine-demo/package.json | 2 +- .../examples/agent-spine-demo/tsconfig.json | 3 + packages/sandbox/sandbox-local/package.json | 2 +- .../sandbox-local/tests/landlock.e2e.ts | 2 +- .../sandbox-local/tests/packed-install.e2e.ts | 35 +- packages/sandbox/sandbox-local/tsconfig.json | 3 + pnpm-lock.yaml | 103 +++--- pnpm-workspace.yaml | 11 +- scripts/check-workspace-constraints.ts | 32 +- scripts/clean.spec.ts | 16 +- scripts/clean.ts | 9 +- scripts/gen-third-party-notices.spec.ts | 4 +- scripts/gen-third-party-notices.ts | 16 +- tsconfig.base.json | 1 + tsconfig.host.json | 1 + 40 files changed, 535 insertions(+), 530 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md create mode 100644 .agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md create mode 100644 .github/workflows/landlock-run-release.yml delete mode 100644 native/landlock-run/pnpm-lock.yaml delete mode 100644 native/landlock-run/pnpm-workspace.yaml diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 7f15a55333..5f8dfa4e65 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: aed5ac1ceb02130ce97a8c83c0f77869fdc32146 -2026-07-06-sandbox.zh.md: db95b1a5b7a7cae1e0fcdd8deba9dcb6ad020a67 +2026-07-06-sandbox.md: 69a3f1bd181bc06d9a176fa45b1e091991cfa682 +2026-07-06-sandbox.zh.md: eeca55b61da24df215f7a9b7ba8dbf9ab2387f20 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index aed5ac1ceb..69a3f1bd18 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -64,7 +64,7 @@ Left open, for the phase that needs them: whether network restriction arrives as The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; every launcher failure exits 125 without running the child and prints a fatal `landlock-run:` line. A successfully exec'd child may also return 125, so status alone is not launcher evidence. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. -The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. +The Landlock launcher source and package family live at `native/landlock-run`, next to the harness consumers and inside the root pnpm workspace. The [in-repository Landlock release decision](../process/2026-08-06-in-repository-landlock-release.md) owns the shared lockfile, native build, pack rehearsal, and npm publication boundary. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement. @@ -118,7 +118,7 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing - **Unit:** pin platform selection and profiles, direct provider-argv handoff, spawn-level failures with invalid-workdir controls, missing/non-executable/missing-interpreter evidence, malformed-runner negative controls, confined `BASH_ENV` ordering, structured runner classification (including partial-Landlock notice-only child outcomes, gated fatal evidence, child exits 126/127, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. -- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip. +- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage installs the current checkout's native tarballs and proves the launcher remains executable and byte-identical. CI rejects a silent all-skip. - **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. - **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bash `false` as an ordinary child result and a missing provider executable as foreground/background infrastructure failure through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent. @@ -134,9 +134,9 @@ Each phase gets its full design when picked up, validated against the code at th - **Command-string heuristic preflight** — rejected: cannot understand expansion/subprocesses/symlinks; the strict attempt (run it, let the kernel decide) is the only trustworthy denial signal. - **Functionally probe even a platform's sole backend** — rejected: probing arbitrates between candidates; with one there is nothing to decide, and probe cost taxes the first confined command of every session (prohibitive for heavy future backends). The runner's own exec-time fail-closed refusal plus structured `runnerFailureRules` classification carries the safety property instead. -- **Commit the built launcher binaries** — rejected: a binary in a diff is unreviewable and churns history; reviewed source + native CI builds + the launcher repo's byte-pinned publish rehearsal keep bytes out of every tree. +- **Commit the built launcher binaries** — rejected: a binary in a diff is unreviewable and churns history; reviewed source + native CI builds + the main repository's byte-pinned publish rehearsal keep bytes out of every tree. - **Compile the launcher on install** — rejected: pushes a C toolchain onto every consumer; a fallback that exists only where a compiler happens to be is not a fallback. -- **Cross-compile both architectures from one builder** — rejected: requires carrying a pinned cross toolchain (rustup targets, zig, or a container image) solely to rebuild two ~70 KB binaries; per-architecture native runners already exist and each builds its own platform package (the `node-addon-require-builtin` model, the launcher repo's own pipeline). +- **Cross-compile both architectures from one builder** — rejected: requires carrying a pinned cross toolchain (rustup targets, zig, or a container image) solely to rebuild two ~70 KB binaries; per-architecture native runners already exist and each builds its own platform package (the `node-addon-require-builtin` model, retained by the main repository's native pipeline). - **No fallback (bwrap or fail closed)** — rejected: concentrates failure on the hosts a sandbox matters most, degrading to `danger-full-access` by resignation. - **Keep the mechanism inside `dsh-bash-sandbox`** — rejected: blocks the existing second consumer, makes future phases read mode out of a bash plugin's config, and cannot express escalation. - **Config-fixed mode on the provider** — rejected: one mode per process; cannot serve concurrent consumers with different policies nor the one-shot widened retry. @@ -175,7 +175,7 @@ Costs and accepted limits: - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. -- **The launcher arrives as a registry dependency.** Trusted through its own repository's release pipeline (reviewed C source, native CI builders, byte-pinned publish rehearsal) plus this repo's version pin — the real-kernel e2e legs are what vouch for behavior through the installed bytes. +- **The launcher is a workspace dependency in source and an npm dependency after publication.** The main repository tests reviewed C source, native CI builds, and byte-pinned local tarballs together before publishing the same package family; the real-kernel e2e legs vouch for behavior through those installed bytes. - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants. - **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone. - **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, through the spawn channel when the selected executable cannot start, or through a structured rule when a started runner refuses — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority. @@ -187,7 +187,7 @@ Costs and accepted limits: - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. - **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]. A bare `syscall: 'spawn'` without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background task stamps `sandbox.runnerFailed` and renders its own marker. A `SubprocessService` that synchronously throws the same provenanced `ENOENT`/`EACCES` shape makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). -- **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime. +- **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the packaged Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. - **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary). - **Does a granted escalation persist?** No. The grant is consumed by the exact foreground or background call that asked; every neighboring call keeps its own effective mode. A later background denial surfaces through `task_output` and may ground a new exact-command retry. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index db95b1a5b7..eeca55b61d 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -64,7 +64,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它为自身安装规则集并执行 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 -Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harness 消费方同仓。独立的 [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) 仓库是用于打包并发布 npm 包族的发布镜像;导出流程归 `native/README.md` 所有。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI 参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 +Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness 消费方同仓,并属于根 pnpm workspace。[仓库内 Landlock 发布决策](../process/2026-08-06-in-repository-landlock-release.md)负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI 参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 后端 profile 共享模式契约但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。 @@ -118,7 +118,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 - **单元测试:** 固定平台选择和 profile、直接交接提供方返回的 argv、带有无效 workdir 对照的 spawn 层失败、runner 缺失/不可执行/解释器缺失证据、格式错误 runner 阴性对照、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 -- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。打包安装测试证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 +- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。打包安装测试会安装当前 checkout 的原生 tarball,并证明 launcher 保持可执行且字节完全一致。CI 拒绝静默全跳过。 - **With-key:** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。 - **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果,并固定提供方可执行文件缺失时在前台/后台均为基础设施失败。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 @@ -134,9 +134,9 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **命令字符串启发式预检**:否决。无法理解展开/子进程/符号链接;严格尝试(运行它,让内核决定)是唯一可信的拒绝信号。 - **即使平台仅有一个后端也功能性探测**:否决。探测用于在候选者之间仲裁;只有一个时无需决策,且探测开销对每个会话的首次约束命令征税(对未来重量级后端而言代价过高)。runner 自身执行时的失败关闭拒绝加结构化 `runnerFailureRules` 分类承载了安全属性。 -- **提交构建好的 launcher 二进制**:否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + launcher 仓库的字节固定发布演练使二进制远离所有代码树。 +- **提交构建好的 launcher 二进制**:否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + 主仓库的字节固定发布演练使二进制远离所有代码树。 - **安装时编译 launcher**:否决。将 C 工具链强加给每个消费方;仅在碰巧有编译器时才存在的备选不是备选。 -- **从一个构建器交叉编译两种架构**:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链(rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(`node-addon-require-builtin` 模式,launcher 仓库自己的流水线)。 +- **从一个构建器交叉编译两种架构**:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链(rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(`node-addon-require-builtin` 模式,由主仓库的原生流水线保留)。 - **无备选(bwrap 或失败关闭)**:否决。将失败集中在沙箱最重要的主机上,最终因放弃而降级到 `danger-full-access`。 - **将机制保留在 `dsh-bash-sandbox` 内部**:否决。阻塞既有的第二个消费方,使未来阶段从一个 bash 插件的配置中读取模式,且无法表达升级。 - **提供方上的配置固定模式**:否决。每进程一个模式;无法服务具有不同策略的并发消费方,也无法表达一次性放宽重试。 @@ -175,7 +175,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 -- **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试环节会验证安装产物的实际行为。 +- **launcher 在源码中是 workspace 依赖,发布后是 npm 依赖。** 主仓库会在发布同一个包家族之前,一起测试经审查的 C 源码、原生 CI 构建和字节固定的本地 tarball;真实内核 e2e 测试环节会验证这些安装字节的实际行为。 - **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 - **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 - **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 `confine()` 阶段失败,所选可执行文件无法启动时通过 spawn 通道失败,已启动的 runner 拒绝时则通过结构化规则失败——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 @@ -187,7 +187,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。 - **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 - **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 -- **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到注册表安装的 Landlock launcher,结论在提供方生命周期内缓存。 +- **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 - **哪些工具实际在约束下运行?** 通过 `ctx.bash` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。 - **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `task_output` 呈现,并且可以作为一次新的精确命令重试的依据。 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml new file mode 100644 index 0000000000..3ce0e0d5e1 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md +2026-08-06-in-repository-landlock-release.md: f682078250adde8d56a4270e9d01ce4b1cd1bee9 +2026-08-06-in-repository-landlock-release.zh.md: 4950d80d87afd18c5605f4f5bca56b8d85564fc2 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md new file mode 100644 index 0000000000..f682078250 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md @@ -0,0 +1,42 @@ +# Agent Note: In-repository Landlock release + +Status: implemented + +English | [中文](2026-08-06-in-repository-landlock-release.zh.md) + +## Problem + +The `node-addon-landlock-run` source already lives beside its DeepSeek Harness consumers under `native/landlock-run`, but it previously kept a separate pnpm workspace and lockfile and depended on a standalone repository for npm publication. Harness packages consumed a fixed registry version, so one pull request could change the launcher contract and its consumer without testing those changes together. The source repository's native workflow could rehearse the package, but it did not publish the artifact it tested. + +The mirror also duplicated release coordination: export the source, update another lockfile, run another release workflow, publish the native family, then return to this repository to bump registry dependencies. That split made source-to-binary provenance, rollback, and security-fix coordination harder without changing what npm users actually needed. + +The consolidation must preserve platform selection. The public distribution is deliberately one JavaScript entry package plus separate Linux x64 and arm64 binary packages; merging repository ownership does not imply putting every binary into one tarball or publishing every DeepSeek Harness package at the launcher version. + +## Decision + +`native/landlock-run` and `native/landlock-run/packages/*` belong to the repository's root pnpm workspace and use the root `pnpm-lock.yaml`. Harness consumers declare `node-addon-landlock-run` with `workspace:*`, so development, type checking, builds, and pull-request tests resolve the entry package from the same checkout. The root TypeScript project graph builds that entry package before consumers, and the repository cleaner owns its direct `lib/` output. + +The public npm boundary remains three packages with one launcher-family version: `node-addon-landlock-run`, `node-addon-landlock-run-linux-x64`, and `node-addon-landlock-run-linux-arm64`. The entry package retains both platform packages as `optionalDependencies`; their `os` and `cpu` manifest fields let npm install only the compatible package. Repository constraints allow public publication only for those three names, require `publishConfig.access: public`, and require their versions to match the private launcher workspace root. Other repository workspaces remain private under the existing constraint. + +The main repository owns both native CI and publication. `Landlock Run` runs for relevant pull requests and `master` pushes and builds each platform on its matching native runner. The manually dispatched `Landlock Run Release` workflow builds both platform binaries, transfers them as workflow artifacts, assembles and verifies the complete package family, packs immutable npm tarballs, installs and exercises those tarballs, and only then permits the protected publish job. Platform tarballs publish before the entry tarball that optionally depends on them. Publication uses `landlock-run-vX.Y.Z` tags so launcher releases cannot collide with other release families in the monorepo; prereleases use the npm `next` dist-tag. + +The sandbox packed-install rehearsal no longer permits the npm registry to supply the launcher. It packs the current checkout's entry and matching native package alongside the harness dependency closure, installs those local tarballs into an external plain-Node consumer, and proves that the installed launcher is executable, byte-identical to the native build, and the correct ELF architecture before testing confinement or fail-closed behavior. + +## Alternatives considered + +- **Keep the standalone repository as a release mirror** — rejected because it preserves the split lockfiles, source export, stale-registry test window, and cross-repository release sequence after the source of record has already moved here. +- **Publish one npm package containing every platform binary** — rejected because users would download binaries they cannot run and npm could no longer use package-level `os`/`cpu` filtering. Repository ownership and npm package layout are separate choices. +- **Give the launcher the root DeepSeek Harness version and publish the complete monorepo recursively** — rejected because this change owns one three-package public family, not the independent `@deepseek-ai/*` baseline. The [artifact-first npm baseline proposal](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md) explicitly keeps native workspaces outside its target set. +- **Cross-compile both binaries in one release job** — rejected because the checked-in package matrix already assigns each architecture a native GitHub runner and avoids adding a cross-toolchain trust surface. + +## Consequences + +Launcher protocol, TypeScript entry code, native source, harness consumption, and publish-path tests can change in one pull request and resolve from one lockfile. A release tag now identifies the source, consumer integration, build instructions, and tarballs tested by the main repository. The standalone mirror is no longer part of the release path and can be archived after the first successful in-repository publication. + +npm consumers keep the same install command and package names. A supported Linux host downloads the entry package and its matching architecture package; the other architecture package is skipped. An unsupported host receives no platform binary and follows the existing deterministic fail-closed probe path. + +The implementation touches more files than a dependency-line edit because the repository must also own workspace constraints, TypeScript build order, cleanup, CI triggers, release tags, lockfile generation, packed-install provenance, release documentation, and generated notices. The behavioral boundary stays narrow: it changes only the Landlock package family and its three direct workspace consumers, not the version or publication state of other DeepSeek Harness packages. + +The main repository's `npm-publish` environment must authorize npm trusted publishing or provide `NPM_TOKEN`; moving workflow code cannot configure those external settings. npm still publishes packages sequentially and offers no cross-package transaction, so a failed publish can leave a partial version. Because npm rejects an already-published name and version, an operator must inspect the registry and publish only the missing tarballs rather than rerunning the workflow unchanged. Linux x64 and arm64 runners remain the authoritative binary and real-kernel checks; a macOS checkout can verify the entry package and unsupported-platform behavior but cannot replace those jobs. + +This note supersedes only the release-mirror and registry-pinned source-development statements in the [sandbox Agent Note](../feature/2026-07-06-sandbox.md); that note continues to own sandbox behavior, runner selection, and enforcement semantics. diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md new file mode 100644 index 0000000000..4950d80d87 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md @@ -0,0 +1,42 @@ +# Agent Note: 仓库内 Landlock 发布 + +Status: implemented + +[English](2026-08-06-in-repository-landlock-release.md) | 中文 + +## 问题 + +`node-addon-landlock-run` 源码已经与其 DeepSeek Harness 消费方一同位于 `native/landlock-run` 下,但此前仍保留独立的 pnpm workspace 和锁文件,并依赖一个独立仓库发布到 npm。Harness 包使用 npm 注册表中的固定版本,因此同一个 PR(Pull Request)可以同时修改启动器契约及其消费方,却无法一起测试这些改动。源码仓库的原生工作流可以演练打包流程,但不会发布它实际测试过的产物。 + +发布镜像还造成重复的发布协调工作:导出源码、更新另一份锁文件、运行另一套发布工作流、发布原生包家族,然后回到本仓库更新注册表依赖。npm 用户的实际需求并未改变,这种拆分却增加了从源码到二进制的溯源、回滚和安全修复协调难度。 + +此次整合必须保留平台选择机制。公开分发有意采用一个 JavaScript 入口包,并为 Linux x64 和 arm64 分别提供二进制包;合并仓库归属并不意味着要把所有二进制文件放进同一个 tarball,也不意味着要按照启动器版本发布所有 DeepSeek Harness 包。 + +## 决策 + +`native/landlock-run` 和 `native/landlock-run/packages/*` 属于仓库根 pnpm workspace,并使用根 `pnpm-lock.yaml`。Harness 消费方将 `node-addon-landlock-run` 声明为 `workspace:*`,因此开发、类型检查、构建和 PR 测试都会从同一个 checkout 解析入口包。根 TypeScript 项目图会先构建该入口包,再构建消费方;仓库清理器负责清理其直接生成的 `lib/` 输出目录。 + +公开 npm 分发边界仍由 3 个包组成,它们共用一个启动器包家族版本:`node-addon-landlock-run`、`node-addon-landlock-run-linux-x64` 和 `node-addon-landlock-run-linux-arm64`。入口包继续通过 `optionalDependencies` 声明两个平台包;它们在 manifest(元数据清单)中的 `os` 和 `cpu` 字段让 npm 只安装兼容的包。仓库约束只允许公开发布这 3 个包名,要求设置 `publishConfig.access: public`,并要求其版本与私有启动器 workspace 根包一致。仓库中的其他 workspace 仍受现有约束保护,保持私有状态。 + +主仓库同时负责原生 CI 和发布。`Landlock Run` 会为相关 PR 和 `master` 推送运行,并在各自匹配的原生 runner 上构建每个平台包。手动触发的 `Landlock Run Release` 工作流会构建两个平台的二进制文件,将其作为工作流产物传递,组装并验证完整的包家族,打包出内容不可变的 npm tarball,安装并实际运行这些 tarball,之后才允许受保护的发布作业执行。发布顺序是平台 tarball 在前,最后发布将它们列为可选依赖的入口 tarball。发布使用 `landlock-run-vX.Y.Z` tag,避免启动器版本与 monorepo 中其他发布家族发生冲突;预发布版本使用 npm 的 `next` dist-tag。 + +沙箱打包安装演练不再允许 npm 注册表提供启动器。它会将当前 checkout 的入口包、匹配的原生包和 harness 依赖闭包一起打包,把这些本地 tarball 安装到仓库外部的纯 Node 消费方中,并在测试约束效果或失败闭合行为之前,证明所安装的启动器可执行、与原生构建产物字节完全一致,且具有正确的 ELF 架构。 + +## 曾考虑的替代方案 + +- **保留独立仓库作为发布镜像**:不予采纳,因为在权威源码已经迁入本仓库后,这仍会保留拆分的锁文件、源码导出、测试使用陈旧注册表版本的时间窗,以及跨仓库发布序列。 +- **发布一个包含所有平台二进制文件的 npm 包**:不予采纳,因为用户会下载无法在其主机上运行的二进制文件,而且 npm 无法再利用包级 `os`/`cpu` 筛选。仓库归属与 npm 包布局是两个彼此独立的选择。 +- **让启动器使用 DeepSeek Harness 根版本,并递归发布整个 monorepo**:不予采纳,因为本次改动负责的是一个由 3 个包组成的公开包家族,而不是独立的 `@deepseek-ai/*` 基线。[产物优先的 npm 基线提案](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md)明确将原生 workspace 排除在其目标集合之外。 +- **在一个发布作业中交叉编译两个二进制文件**:不予采纳,因为仓库内已提交的包矩阵已经为每种架构分配了原生 GitHub runner,无需再把交叉工具链纳入信任边界。 + +## 后果 + +同一个 PR 可以同时修改启动器协议、TypeScript 入口代码、原生源码、harness 消费方式和发布路径测试,并从同一份锁文件解析这些内容。发布 tag 现在标识源码、消费方集成、构建指令,以及主仓库测试过的 tarball。第一次成功从本仓库发布后,独立镜像便不再属于发布路径,可以归档。 + +npm 消费方继续使用相同的安装命令和包名。受支持的 Linux 主机会下载入口包及与其架构匹配的包,并跳过另一架构的包。不受支持的主机不会收到平台二进制文件,并继续沿用现有的确定性失败闭合探测路径。 + +实现涉及的文件比只修改一行依赖更多,因为仓库还必须负责 workspace 约束、TypeScript 构建顺序、清理、CI 触发条件、发布 tag、锁文件生成、打包安装来源证明、发布文档和生成的第三方声明。行为边界仍然很窄:此次改动只影响 Landlock 包家族及其 3 个直接 workspace 消费方,不改变其他 DeepSeek Harness 包的版本或发布状态。 + +主仓库的 `npm-publish` 环境必须授权 npm trusted publishing,或提供 `NPM_TOKEN`;只迁移工作流代码无法配置这些外部设置。npm 仍会按顺序发布各个包,且不提供跨包事务,因此发布失败可能留下只完成了一部分的版本。由于 npm 会拒绝已经发布的同名同版本包,操作人员必须检查注册表并只发布缺失的 tarball,而不能原样重新运行工作流。Linux x64 和 arm64 runner 仍提供权威的二进制构建与真实内核检查;macOS checkout 可以验证入口包和不受支持平台上的行为,但不能取代这些作业。 + +本说明仅取代[沙箱 Agent Note](../feature/2026-07-06-sandbox.md)中有关发布镜像和开发源码时依赖注册表固定版本的表述;该 Agent Note 仍负责沙箱行为、runner 选择和强制执行语义。 diff --git a/.github/workflows/landlock-run-release.yml b/.github/workflows/landlock-run-release.yml new file mode 100644 index 0000000000..dca6c9eed1 --- /dev/null +++ b/.github/workflows/landlock-run-release.yml @@ -0,0 +1,170 @@ +# Build and publish the node-addon-landlock-run package family from the +# harness source of record. Rehearsal and publication consume the same packed +# tarballs; each native binary is built on its matching architecture. +name: Landlock Run Release + +on: + workflow_dispatch: + inputs: + publish: + description: Publish packed tarballs to npm. Must run from a landlock-run-v* tag. + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + # Stable/prerelease dist-tags are shared registry state; serialize release + # runs so two versions cannot race the final tag assignment. + group: ${{ github.workflow }} + cancel-in-progress: false + +defaults: + run: + working-directory: native/landlock-run + +jobs: + matrix: + name: Matrix + runs-on: ubuntu-24.04 + outputs: + prebuilds: ${{ steps.matrix.outputs.prebuilds }} + steps: + - uses: actions/checkout@v4 + + - id: matrix + run: echo "prebuilds=$(node ./scripts/github-matrix.mjs release-prebuild)" >> "$GITHUB_OUTPUT" + + build-prebuilds: + name: ${{ matrix.package }} + needs: matrix + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.prebuilds) }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + + - name: Install musl toolchain + run: | + sudo apt-get update -q + sudo apt-get install -yq musl-tools + + - name: Build native binaries + run: pnpm build:native + + - name: Verify binary metadata + run: node ./scripts/verify-launcher-binary.mjs ${{ matrix.dir }} + + - name: Upload prebuild artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: native/landlock-run/${{ matrix.dir }}/bin/* + if-no-files-found: error + retention-days: 7 + + pack: + name: Pack npm tarballs + needs: build-prebuilds + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + + - name: Build TypeScript + run: pnpm build:ts + + - name: Verify release version + run: node ./scripts/verify-release.mjs + env: + RELEASE_PUBLISH: ${{ inputs.publish }} + + - name: Download prebuild artifacts + uses: actions/download-artifact@v4 + with: + pattern: prebuild-* + path: native/landlock-run/.release/prebuild-artifacts + + - name: Assemble and verify prebuilds + run: node ./scripts/assemble-prebuilds.mjs .release/prebuild-artifacts + + - name: Verify release payload + run: node ./scripts/verify-release.mjs --prebuilds + env: + RELEASE_PUBLISH: ${{ inputs.publish }} + + - name: Pack release tarballs + run: node ./scripts/pack-release.mjs dist/npm + + - name: Verify packed install + run: node ./scripts/verify-packed-install.mjs dist/npm + env: + NALR_REQUIRE_LANDLOCK: 1 + + - name: Upload npm tarballs + uses: actions/upload-artifact@v4 + with: + name: npm-tarballs + path: native/landlock-run/dist/npm/* + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to npm + if: inputs.publish + needs: pack + runs-on: ubuntu-24.04 + environment: npm-publish + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Download npm tarballs + uses: actions/download-artifact@v4 + with: + name: npm-tarballs + path: native/landlock-run/dist/npm + + - name: Publish tarballs + run: | + version="${GITHUB_REF#refs/tags/landlock-run-v}" + tag_args=() + case "$version" in *-*) tag_args=(--tag next);; esac + while IFS= read -r tarball; do + npm publish "dist/npm/${tarball}" --access public "${tag_args[@]}" + done < dist/npm/publish-order.txt + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/landlock-run.yml b/.github/workflows/landlock-run.yml index dad9638761..391e1eeae1 100644 --- a/.github/workflows/landlock-run.yml +++ b/.github/workflows/landlock-run.yml @@ -1,15 +1,27 @@ -# Manually-dispatched CI for the landlock-run source of record -# (native/landlock-run). A separate workflow from ci.yml on purpose: the -# subtree is a self-contained pnpm workspace with its own gates, exercised on -# demand — per-architecture native legs (build + behavioral tests + pack -# rehearsal on real kernels) plus one darwin leg proving the documented -# degradation on hosts without a platform package. Legs derive from the -# subtree's checked-in package matrix (scripts/github-matrix.mjs). Packing -# for npm happens in the release mirror (node-addon-landlock-run) after an -# export — see native/README.md; this workflow never packs for release. +# CI for the landlock-run packages under native/landlock-run. A separate +# workflow from ci.yml keeps the native OS/architecture matrix independent of +# the harness Node matrix. Release assembly and publication use the companion +# Landlock Run Release workflow. name: Landlock Run on: + pull_request: + paths: + - '.github/workflows/landlock-run.yml' + - '.github/workflows/landlock-run-release.yml' + - 'native/landlock-run/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + push: + branches: [master] + paths: + - '.github/workflows/landlock-run.yml' + - '.github/workflows/landlock-run-release.yml' + - 'native/landlock-run/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' workflow_dispatch: concurrency: @@ -52,16 +64,16 @@ jobs: - uses: pnpm/action-setup@v4 with: - package_json_file: native/landlock-run/package.json + package_json_file: package.json - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - cache-dependency-path: native/landlock-run/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile - name: Install musl toolchain run: | @@ -103,16 +115,16 @@ jobs: - uses: pnpm/action-setup@v4 with: - package_json_file: native/landlock-run/package.json + package_json_file: package.json - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - cache-dependency-path: native/landlock-run/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile - name: Build TypeScript run: pnpm build:ts diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 939ca2f6ab..2dfaf0e175 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -3,9 +3,8 @@ # .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md. # A separate workflow from ci.yml because the axis is different — these jobs # fan out over OS×runner (kernel capabilities), not node versions. The Landlock -# launcher arrives from the registry with `pnpm install` (the npm package family -# `node-addon-landlock-run`, built and released from its own repository), so -# these legs exercise the true consumer path — nothing is compiled here. +# launcher is built from native/landlock-run on each Landlock leg and installed +# from the same tarballs the main-repository release workflow publishes. name: Sandbox on: @@ -30,7 +29,7 @@ jobs: # an OS×runner matrix — bwrap and Landlock on Linux (separate legs: the # Landlock files force the bwrap rung off, so each leg proves exactly one # rung; Landlock twice, once per architecture, each confining through the - # registry-installed launcher), Seatbelt on macOS (sandbox-exec ships with + # locally built launcher), Seatbelt on macOS (sandbox-exec ships with # the OS). One node # version only: kernel confinement does not vary by node, and ci.yml's # node matrix already covers the node axis. @@ -80,6 +79,14 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ || echo "apparmor userns knob absent — the functional probe decides" + - name: Build Landlock launcher for this architecture + if: matrix.runner == 'landlock' + run: | + sudo apt-get update -q + sudo apt-get install -yq musl-tools + pnpm --dir native/landlock-run run build:ts + pnpm --dir native/landlock-run run build:native + # The unit suite runs on ubuntu in `checks`; this is the one darwin leg # in the workflow, so run it here too — the platform-dependent unit # expectations (Seatbelt path canonicalization: /tmp IS /private/tmp) @@ -105,14 +112,10 @@ jobs: # the very platform that exists to prove it) is a failure, not a pass. echo "$out" | grep -qE 'Test Files[[:space:]]+2 passed \(2\)' - # Publish-path rehearsal, Landlock legs only (the pack gates need built - # lib/). The e2e packs the workspace closure, installs the tarballs - # into a throwaway consumer — npm pulling `node-addon-landlock-run` - # and its platform package from the registry, the true consumer path — - # and confines through the INSTALLED launcher, asserting it executable - # apart (a mode-stripped binary must not masquerade as a non-enforcing - # kernel). Same no-silent-skip guard as above. - - name: Build packages (lib/ for the pack rehearsal) + # Publish-path rehearsal, Landlock legs only. Build the launcher on its + # native architecture, then install the local native and harness tarballs + # together so registry state cannot mask source/package drift. + - name: Build packages for the pack rehearsal if: matrix.runner == 'landlock' run: pnpm run build diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7d2e257ea9..aa73dba8d9 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -170,6 +170,6 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | --- | --- | --- | | [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable | -## First-party sibling releases +## First-party native packages -`node-addon-landlock-run` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +`node-addon-landlock-run` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. diff --git a/knip.json b/knip.json index dfb8058d7c..60853c6517 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,7 @@ ], "ignoreBinaries": [ "bwrap", + "musl-gcc", "python3", "sandbox-exec", "taskkill" diff --git a/native/README.i18n.yaml b/native/README.i18n.yaml index a55273be29..31a86ebe73 100644 --- a/native/README.i18n.yaml +++ b/native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/README.md -README.md: a79d9ca5747d4c4fbfa50745b3eece07b96aea58 -README.zh.md: 708430c0a087e5a9da1dae6fd078cb477db65d9b +README.md: 51c8da7b57df15e65b8e431ee18ce6cebb89d54b +README.zh.md: baf530157c6731893a66aea301c6dd962592defc diff --git a/native/README.md b/native/README.md index a79d9ca574..51c8da7b57 100644 --- a/native/README.md +++ b/native/README.md @@ -2,12 +2,10 @@ English | [中文](README.zh.md) -Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then-exec launcher consumed by the harness. The [`landlock-run/` workspace](landlock-run/README.md) owns its architecture, package family, platform support, development workflow, and release procedure. The standalone repository is a release mirror. +Native source and public packages maintained with DeepSeek Harness. The [`landlock-run/` workspace](landlock-run/README.md) owns the Landlock self-restrict-then-exec launcher consumed by the harness, including its architecture, three-package npm family, platform support, development workflow, and [release procedure](landlock-run/docs/release.md). -## Release mirror +## Workspace and release boundary -| Directory | Mirror repo | Last exported release | Commit | -|---|---|---|---| -| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | +`landlock-run/` and its packages belong to the repository's root pnpm workspace and lockfile. Harness consumers use the current workspace entry package during development and CI, so a launcher contract change and its consumer update can land and be tested together. -The subtree is a self-contained pnpm workspace and is not part of the harness workspace. The [launcher release reference](landlock-run/docs/release.md) owns the export and publication workflow. The mirror must not diverge: port any direct mirror hotfix back here before the next export. +The main repository's `Landlock Run` workflow builds and tests each supported architecture. `Landlock Run Release` assembles those native artifacts, packs and verifies the three npm tarballs, then optionally publishes them under one launcher version. The entry package retains platform packages as npm optional dependencies, so npm still installs only the package matching the user's operating system and CPU. diff --git a/native/README.zh.md b/native/README.zh.md index 708430c0a0..baf530157c 100644 --- a/native/README.zh.md +++ b/native/README.zh.md @@ -2,12 +2,10 @@ [English](README.md) | 中文 -`node-addon-landlock-run` 的真源;它是供 harness 使用、先施加 Landlock 自限再执行命令的启动器。[`landlock-run/` workspace](landlock-run/README.md)负责其架构、包家族、平台支持、开发工作流和发布流程。独立仓库是发布镜像。 +与 DeepSeek Harness 一同维护的原生源码和公开包。[`landlock-run/` workspace](landlock-run/README.md)负责 harness 使用的 Landlock 自限后执行启动器,包括其架构、由三个包组成的 npm 包家族、平台支持、开发工作流和[发布流程](landlock-run/docs/release.md)。 -## 发布镜像 +## Workspace 与发布边界 -| 目录 | 镜像仓库 | 最近导出的版本 | Commit | -|---|---|---|---| -| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | +`landlock-run/` 及其包属于仓库根 pnpm workspace,并共用根锁文件。开发和 CI 中的 harness 消费方直接使用当前 workspace 的入口包,因此启动器契约变更与消费方更新可以在同一个改动中落地并一起测试。 -该子树是自包含的 pnpm workspace,不属于 harness workspace。[启动器发布参考](landlock-run/docs/release.md)负责导出和发布工作流。镜像不得发生分歧:下次导出前,必须把任何直接施加于镜像的热修复移植回此处。 +主仓库的 `Landlock Run` 工作流为每个受支持架构构建并测试。`Landlock Run Release` 汇集这些原生产物,打包并验证三个 npm tarball,随后可选择以同一个启动器版本发布。入口包继续将平台包声明为 npm 可选依赖,因此 npm 仍然只会安装与用户操作系统和 CPU 匹配的包。 diff --git a/native/landlock-run/AGENTS.md b/native/landlock-run/AGENTS.md index 31e12e177c..6742ff81d4 100644 --- a/native/landlock-run/AGENTS.md +++ b/native/landlock-run/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -This workspace builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. The source of record is the `deepseek-harness` repository's `native/landlock-run/`; the `node-addon-landlock-run` repository is the release mirror this tree is exported to for packing and publishing (procedure: `native/README.md` in the harness repo). Make changes in the source of record, never only in the mirror. +This directory builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. It belongs to the repository's root pnpm workspace and lockfile. The main repository owns native CI, tarball assembly, verification, and npm publication; keep package-family changes coordinated with harness consumers in the same repository. ## Pre-release stance diff --git a/native/landlock-run/docs/release.md b/native/landlock-run/docs/release.md index e1ea65c411..a95cffd47f 100644 --- a/native/landlock-run/docs/release.md +++ b/native/landlock-run/docs/release.md @@ -4,45 +4,45 @@ Pre-1.0: treat this as a release checklist, not a stability policy. ## Versioning -One version across every package in the repo. Use the bump helper: +The launcher workspace root and its three public packages share one version. Run the bump helper from the repository root: ```sh -pnpm release:bump patch # or minor / major / x.y.z +pnpm --dir native/landlock-run release:bump patch # or minor / major / x.y.z ``` -It updates the root and every `packages/*` manifest, refreshes the lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack. +It updates `native/landlock-run/package.json` and every `native/landlock-run/packages/*` manifest, refreshes the repository root lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm --dir native/landlock-run release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack. -Version bumps are normal source changes: open a release PR (or commit) with the manifests and lockfile, merge it, then create the matching `vX.Y.Z` tag from that commit. The publish workflow validates that the tag matches every package version. +Version bumps are normal source changes: open a release PR (or commit) with the launcher manifests and root lockfile, merge it, then create the matching `landlock-run-vX.Y.Z` tag from that commit. The namespace avoids colliding with release tags for other package families in the repository. The publish workflow validates that the tag matches every launcher package version. ```sh -pnpm release:commit patch # bump + stage + commit in one command -git tag v0.0.2 +pnpm --dir native/landlock-run release:commit patch # bump + stage + commit in one command +git tag landlock-run-v0.0.2 ``` ## Preflight ```sh pnpm install --frozen-lockfile -pnpm build:ts -pnpm typecheck -pnpm test:entry +pnpm --dir native/landlock-run build:ts +pnpm --dir native/landlock-run typecheck +pnpm --dir native/landlock-run test:entry ``` On a Linux host, also rehearse the pack path locally: ```sh -pnpm build:native -pnpm test:launcher -node ./scripts/pack-release.mjs .release/npm --current-platform-only -node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only +pnpm --dir native/landlock-run build:native +pnpm --dir native/landlock-run test:launcher +node native/landlock-run/scripts/pack-release.mjs native/landlock-run/.release/npm --current-platform-only +node native/landlock-run/scripts/verify-packed-install.mjs native/landlock-run/.release/npm --current-platform-only ``` ## Publish -Use the `Release` workflow so every binary is built on its matching native runner: +Use the main repository's `Landlock Run Release` workflow so every binary is built on its matching native runner: 1. Run it with `publish=false` (from the release commit) to build all platform binaries, assemble and verify the payloads, pack the tarballs in publish order, rehearse the packed install, and upload the `npm-tarballs` artifact for inspection. -2. Create and push the `vX.Y.Z` tag matching the package versions. +2. Create and push the `landlock-run-vX.Y.Z` tag matching the package versions. 3. Run the same workflow from that tag with `publish=true`. The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). It supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. @@ -50,9 +50,9 @@ The workflow publishes only from the final packed tarballs, in `publish-order.tx Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)): ```sh -node ./scripts/pack-release.mjs dist/npm --current-platform-only -node ./scripts/verify-packed-install.mjs dist/npm --current-platform-only -while IFS= read -r tarball; do npm publish "dist/npm/${tarball}" --access public; done < dist/npm/publish-order.txt +node native/landlock-run/scripts/pack-release.mjs native/landlock-run/dist/npm --current-platform-only +node native/landlock-run/scripts/verify-packed-install.mjs native/landlock-run/dist/npm --current-platform-only +while IFS= read -r tarball; do npm publish "native/landlock-run/dist/npm/${tarball}" --access public; done < native/landlock-run/dist/npm/publish-order.txt ``` Do not commit `.npmrc` files with tokens or registry overrides. diff --git a/native/landlock-run/pnpm-lock.yaml b/native/landlock-run/pnpm-lock.yaml deleted file mode 100644 index dd309fb046..0000000000 --- a/native/landlock-run/pnpm-lock.yaml +++ /dev/null @@ -1,345 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - '@types/node': - specifier: ^26.0.1 - version: 26.0.1 - node-addon-landlock-run: - specifier: workspace:* - version: link:packages/entry - tsx: - specifier: ^4.20.6 - version: 4.23.0 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - - packages/entry: - optionalDependencies: - node-addon-landlock-run-linux-arm64: - specifier: workspace:* - version: link:../linux-arm64 - node-addon-landlock-run-linux-x64: - specifier: workspace:* - version: link:../linux-x64 - - packages/linux-arm64: {} - - packages/linux-x64: {} - -packages: - - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - tsx@4.23.0: - resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} - engines: {node: '>=18.0.0'} - hasBin: true - - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - -snapshots: - - '@esbuild/aix-ppc64@0.28.1': - optional: true - - '@esbuild/android-arm64@0.28.1': - optional: true - - '@esbuild/android-arm@0.28.1': - optional: true - - '@esbuild/android-x64@0.28.1': - optional: true - - '@esbuild/darwin-arm64@0.28.1': - optional: true - - '@esbuild/darwin-x64@0.28.1': - optional: true - - '@esbuild/freebsd-arm64@0.28.1': - optional: true - - '@esbuild/freebsd-x64@0.28.1': - optional: true - - '@esbuild/linux-arm64@0.28.1': - optional: true - - '@esbuild/linux-arm@0.28.1': - optional: true - - '@esbuild/linux-ia32@0.28.1': - optional: true - - '@esbuild/linux-loong64@0.28.1': - optional: true - - '@esbuild/linux-mips64el@0.28.1': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - - '@types/node@26.0.1': - dependencies: - undici-types: 8.3.0 - - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - - fsevents@2.3.3: - optional: true - - tsx@4.23.0: - dependencies: - esbuild: 0.28.1 - optionalDependencies: - fsevents: 2.3.3 - - typescript@6.0.3: {} - - undici-types@8.3.0: {} diff --git a/native/landlock-run/pnpm-workspace.yaml b/native/landlock-run/pnpm-workspace.yaml deleted file mode 100644 index 22299bfea0..0000000000 --- a/native/landlock-run/pnpm-workspace.yaml +++ /dev/null @@ -1,8 +0,0 @@ -packages: - - packages/* - -# pnpm 10+ blocks any dependency shipping an install/build script until it is -# explicitly reviewed here. Deny by default; esbuild (tsx's bundled native -# binary) genuinely needs its script. -allowBuilds: - esbuild: true diff --git a/native/landlock-run/scripts/bump-release.mjs b/native/landlock-run/scripts/bump-release.mjs index 29a7777379..55033eed09 100644 --- a/native/landlock-run/scripts/bump-release.mjs +++ b/native/landlock-run/scripts/bump-release.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Bump every package (workspace root + packages/*) to one version, refresh - * the lockfile, and verify. Usage: `pnpm release:bump `. + * Bump the launcher workspace root and packages/* to one version, refresh the + * repository lockfile, and verify. Usage: `pnpm release:bump `. */ import fs from 'node:fs'; @@ -11,14 +11,15 @@ import { packageDirs, readJson, root } from './repo.mjs'; const bump = process.argv[2]; const releaseTypes = new Set(['major', 'minor', 'patch']); +const repositoryRoot = path.resolve(root, '../..'); function writeJson(file, value) { fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); } -function run(command, args) { +function run(command, args, cwd = root) { const result = spawnSync(command, args, { - cwd: root, + cwd, stdio: 'inherit', env: { ...process.env, CI: 'true' }, }); @@ -84,7 +85,7 @@ for (const file of files) { console.log(`${file}: ${targetVersion}`); } -run('pnpm', ['install', '--ignore-scripts', '--lockfile-only']); +run('pnpm', ['install', '--ignore-scripts', '--lockfile-only'], repositoryRoot); run('node', ['./scripts/verify-release.mjs']); console.log(`Release version bumped to ${targetVersion}`); diff --git a/native/landlock-run/scripts/commit-release.mjs b/native/landlock-run/scripts/commit-release.mjs index b7bf3e513b..1b1c78bce5 100644 --- a/native/landlock-run/scripts/commit-release.mjs +++ b/native/landlock-run/scripts/commit-release.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * Bump, stage, and commit a release in one command: - * `pnpm release:commit `. The tag stays manual — - * create it from the merged release commit. + * `pnpm release:commit `. The namespaced tag stays + * manual — create it from the merged release commit. */ import path from 'node:path'; @@ -35,8 +35,8 @@ run('git', [ 'add', 'package.json', 'packages/*/package.json', - 'pnpm-lock.yaml', + '../../pnpm-lock.yaml', ]); -run('git', ['commit', '-m', `release: ${version}`]); +run('git', ['commit', '-m', `release(landlock-run): ${version}`]); -console.log(`Committed release ${version}. Create the tag manually: git tag v${version}`); +console.log(`Committed release ${version}. Create the tag manually: git tag landlock-run-v${version}`); diff --git a/native/landlock-run/scripts/repo.mjs b/native/landlock-run/scripts/repo.mjs index 8032d3da37..db5ffaf28d 100644 --- a/native/landlock-run/scripts/repo.mjs +++ b/native/landlock-run/scripts/repo.mjs @@ -12,10 +12,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; export const root = fileURLToPath(new URL('..', import.meta.url)); -export const packagesRoot = path.join(root, 'packages'); +const packagesRoot = path.join(root, 'packages'); /** ELF `e_machine` (offset 18, little-endian) per platform-package `cpu` value. */ -export const E_MACHINE = { x64: 62, arm64: 183 }; +const E_MACHINE = { x64: 62, arm64: 183 }; export function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); diff --git a/native/landlock-run/scripts/verify-release.mjs b/native/landlock-run/scripts/verify-release.mjs index e812b34a14..ff3917b4b5 100644 --- a/native/landlock-run/scripts/verify-release.mjs +++ b/native/landlock-run/scripts/verify-release.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * Release verification. Always: every published package carries one shared - * version, and — when running from a tag or publishing — the `vX.Y.Z` tag - * matches it. With `--prebuilds`: every platform package's declared + * version, and — when running from a tag or publishing — the + * `landlock-run-vX.Y.Z` tag matches it. With `--prebuilds`: every platform package's declared * binaries exist with the right ELF architecture (run after * `assemble-prebuilds.mjs` or a local `build:native`). */ @@ -10,6 +10,8 @@ import path from 'node:path'; import { packageDirs, platformDirs, readJson, root, verifyPlatformBinaries } from './repo.mjs'; +const TAG_PREFIX = 'refs/tags/landlock-run-v'; + function verifyVersions() { const packages = packageDirs().map((dir) => ({ dir, @@ -26,13 +28,13 @@ function verifyVersions() { const version = packages[0].manifest.version; const ref = process.env.GITHUB_REF || ''; const publish = process.env.RELEASE_PUBLISH === 'true'; - if (publish && !ref.startsWith('refs/tags/v')) { - throw new Error('publishing requires running the workflow from a v* tag'); + if (publish && !ref.startsWith(TAG_PREFIX)) { + throw new Error('publishing requires running the workflow from a landlock-run-v* tag'); } - if (ref.startsWith('refs/tags/v')) { - const tagVersion = ref.slice('refs/tags/v'.length); + if (ref.startsWith(TAG_PREFIX)) { + const tagVersion = ref.slice(TAG_PREFIX.length); if (tagVersion !== version) { - throw new Error(`tag/version mismatch: tag v${tagVersion}, packages ${version}`); + throw new Error(`tag/version mismatch: tag landlock-run-v${tagVersion}, packages ${version}`); } } diff --git a/package.json b/package.json index 7bd84db93a..8340580e6d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "workspaces": [ "vendor/*", "packages/*/*", + "native/landlock-run", + "native/landlock-run/packages/*", "apps/*", "website" ], diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index e3d3c3deb3..6a29c3f6c8 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "cordis": "^4.0.0-rc.7", - "node-addon-landlock-run": "0.0.0-test.0" + "node-addon-landlock-run": "workspace:*" } } diff --git a/packages/bash/bash-sandbox/tsconfig.json b/packages/bash/bash-sandbox/tsconfig.json index fcd79e0296..7a7d67f0cb 100644 --- a/packages/bash/bash-sandbox/tsconfig.json +++ b/packages/bash/bash-sandbox/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../native/landlock-run/packages/entry" + }, { "path": "../../util/brand" }, diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index a4b18b9c0c..b9f817d6e5 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -84,7 +84,7 @@ "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "node-addon-landlock-run": "0.0.0-test.0", + "node-addon-landlock-run": "workspace:*", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 6a0091a6f6..f245e9f4d0 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../../native/landlock-run/packages/entry" + }, { "path": "../../llm/llm" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 2683ab3654..f7004c4d5c 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -31,7 +31,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { - "node-addon-landlock-run": "0.0.0-test.0", + "node-addon-landlock-run": "workspace:*", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts index f5ecbc67f9..6e2faecc6b 100644 --- a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts @@ -10,7 +10,7 @@ import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** - * Keyless backend integration through `confine()` and the registry `landlock-run` launcher, with + * Keyless backend integration through `confine()` and the workspace `landlock-run` launcher, with * bwrap forced off. Tests assert real world effects; consumer coverage lives in dsh-bash-sandbox. * Skips when the platform package or enforcing kernel is unavailable. HOME-based workspaces avoid * Landlock's wholesale `/tmp` grant, so workspace-write proves the workspace-root grant itself. diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index a032e1add7..caf0a32c68 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -7,20 +7,22 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' /** - * Keyless publish-path rehearsal. It packs the package and workspace peers, installs those exact - * tarballs in an external plain-Node consumer, and lets npm resolve the registry Landlock launcher - * plus its platform package. No tsx, path mapping, or workspace resolution can hide missing files, - * dependency errors, or lost executable modes. + * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current + * repository's Landlock entry/platform packages, then installs those exact tarballs in an external + * plain-Node consumer. No registry copy, tsx, path mapping, or workspace resolution can hide + * missing files, dependency errors, or lost executable modes. * * The installed launcher must match the host architecture, remain executable, and either confine a * real process with bwrap disabled or fail closed on a non-enforcing kernel. Skips off Linux or - * before `pnpm run build`; launcher byte provenance belongs to its upstream release pipeline. + * before the harness and native packages are built. */ const packageDir = fileURLToPath(new URL('..', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)) +const nativeDir = join(repoRoot, 'native/landlock-run') +const sourceLauncher = join(nativeDir, 'packages', `linux-${process.arch}`, 'bin', 'landlock-run') -/** The closure the consumer needs: the package and its transitive `@deepseek-ai` peers; the launcher family arrives from the registry. */ +/** The harness closure the consumer needs; native tarballs are packed through their mode-preserving release script. */ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox-local', 'packages/sandbox/sandbox', @@ -36,6 +38,8 @@ const E_MACHINE = { x64: 62, arm64: 183 }[process.arch as 'x64' | 'arm64'] const packable = process.platform === 'linux' && E_MACHINE !== undefined && existsSync(join(packageDir, 'lib', 'index.js')) + && existsSync(join(nativeDir, 'packages/entry/lib/index.js')) + && existsSync(sourceLauncher) let consumerDir = '' let workDir = '' @@ -57,7 +61,20 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- consumerDir = mkdtempSync(join(tmpdir(), 'dsh-packed-consumer-')) workDir = mkdtempSync(join(tmpdir(), 'dsh-packed-work-')) - // Pack each closure member with the exact bytes publish would upload. + const nativePackDest = join(packDest, 'native') + const nativePack = spawnSync('node', ['./scripts/pack-release.mjs', nativePackDest, '--current-platform-only'], { + cwd: nativeDir, + encoding: 'utf8', + timeout: 120_000, + }) + expect(nativePack.status, `native pack failed:\n${nativePack.stdout}\n${nativePack.stderr}`).toBe(0) + + const nativeTarballs = readFileSync(join(nativePackDest, 'publish-order.txt'), 'utf8') + .trim() + .split('\n') + .map(tarball => join(nativePackDest, tarball)) + + // Pack each harness closure member with the exact bytes publish would upload. const tarballs: string[] = [] for (const pkg of WORKSPACE_CLOSURE) { const pack = spawnSync('pnpm', ['pack', '--pack-destination', packDest], { @@ -69,6 +86,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- const lines = pack.stdout.trim().split('\n') tarballs.push(lines[lines.length - 1] as string) } + tarballs.push(...nativeTarballs) // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional // dependencies because the launcher selects its OS/CPU package through one. @@ -124,12 +142,13 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- await Promise.all([consumerDir, workDir].filter(Boolean).map(dir => rm(dir, { recursive: true, force: true }))) }) - it('installs the registry launcher for this host: present, EXECUTABLE, right ELF arch', () => { + it('installs this checkout\'s launcher for the host: present, executable, byte-identical, and right ELF arch', () => { const installed = join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run') expect(existsSync(installed), 'platform package missing from the installed tree').toBe(true) // A tarball or extraction step that strips the mode bit would leave the // probe failing exactly like a non-enforcing kernel — assert it apart. expect(() => { accessSync(installed, constants.X_OK) }, 'installed launcher is not executable').not.toThrow() + expect(readFileSync(installed), 'installed launcher bytes').toEqual(readFileSync(sourceLauncher)) expect(readFileSync(installed).readUInt16LE(18), 'ELF e_machine').toBe(E_MACHINE) }) diff --git a/packages/sandbox/sandbox-local/tsconfig.json b/packages/sandbox/sandbox-local/tsconfig.json index 608f0e9568..7a41ffc5fd 100644 --- a/packages/sandbox/sandbox-local/tsconfig.json +++ b/packages/sandbox/sandbox-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../../native/landlock-run/packages/entry" + }, { "path": "../../llm/llm" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01a5a2f64c..feedce51c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -851,6 +851,34 @@ importers: specifier: workspace:* version: link:../packages/context/workspace-context + native/landlock-run: + devDependencies: + '@types/node': + specifier: ^26.0.1 + version: 26.1.2 + node-addon-landlock-run: + specifier: workspace:* + version: link:packages/entry + tsx: + specifier: ^4.20.6 + version: 4.22.4 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + native/landlock-run/packages/entry: + optionalDependencies: + node-addon-landlock-run-linux-arm64: + specifier: workspace:* + version: link:../linux-arm64 + node-addon-landlock-run-linux-x64: + specifier: workspace:* + version: link:../linux-x64 + + native/landlock-run/packages/linux-arm64: {} + + native/landlock-run/packages/linux-x64: {} + packages/acp/acp: dependencies: '@agentclientprotocol/sdk': @@ -986,8 +1014,8 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis node-addon-landlock-run: - specifier: 0.0.0-test.0 - version: 0.0.0-test.0 + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry packages/bash/pwsh-local: dependencies: @@ -1306,7 +1334,7 @@ importers: version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -2939,8 +2967,8 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis node-addon-landlock-run: - specifier: 0.0.0-test.0 - version: 0.0.0-test.0 + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry packages/examples/cli-demo: devDependencies: @@ -4219,8 +4247,8 @@ importers: packages/sandbox/sandbox-local: dependencies: node-addon-landlock-run: - specifier: 0.0.0-test.0 - version: 0.0.0-test.0 + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry schemastery: specifier: ^3.18.0 version: link:../../../vendor/schemastery @@ -5464,7 +5492,7 @@ importers: version: link:../loader-smoke vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -9113,6 +9141,9 @@ packages: '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/picomatch@3.0.2': resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} @@ -11011,22 +11042,6 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-addon-landlock-run-linux-arm64@0.0.0-test.0: - resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==} - engines: {node: '>=20'} - cpu: [arm64] - os: [linux] - - node-addon-landlock-run-linux-x64@0.0.0-test.0: - resolution: {integrity: sha512-eXvdfnH/UV55MTZzroKvM3CD68SP5OlCsuth908YOcJOnn0LPD5KJjmBz6ToDlBYjF52NNK62+g7TvmUWjbKWQ==} - engines: {node: '>=20'} - cpu: [x64] - os: [linux] - - node-addon-landlock-run@0.0.0-test.0: - resolution: {integrity: sha512-c5qopltRonjW6+VinXYMp4FVi9Sxf8eQEb/9G82EksQQ+JC4CDprv+ko5URWmtyZ3wD4GFdeRTyw1AG5yCwJhQ==} - engines: {node: '>=20'} - node-addon-native-custom-loader@0.1.4: resolution: {integrity: sha512-DreegO6EoC1JHWYBv3j8Miwp2Zl/CyBeNyoeyCbnEdjyYFEulR4Gcb3wj9fXF7KMDY0ZJ5MWwHcXP8GVNyScnA==} engines: {node: '>=20'} @@ -11815,6 +11830,9 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -14189,6 +14207,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + '@types/picomatch@3.0.2': {} '@types/prop-types@15.7.15': {} @@ -14344,13 +14366,13 @@ snapshots: optionalDependencies: vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.8': dependencies: @@ -16476,17 +16498,6 @@ snapshots: node-addon-api@7.1.1: {} - node-addon-landlock-run-linux-arm64@0.0.0-test.0: - optional: true - - node-addon-landlock-run-linux-x64@0.0.0-test.0: - optional: true - - node-addon-landlock-run@0.0.0-test.0: - optionalDependencies: - node-addon-landlock-run-linux-arm64: 0.0.0-test.0 - node-addon-landlock-run-linux-x64: 0.0.0-test.0 - node-addon-native-custom-loader@0.1.4: {} node-addon-require-builtin-darwin-arm64@0.1.4: @@ -17398,6 +17409,8 @@ snapshots: undici-types@7.24.6: {} + undici-types@8.3.0: {} + undici@7.28.0: {} unicorn-magic@0.3.0: {} @@ -17533,7 +17546,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -17541,7 +17554,7 @@ snapshots: rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.1.2 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 @@ -17635,10 +17648,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -17655,7 +17668,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -17695,10 +17708,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -17715,7 +17728,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fec185b114..66510d89ec 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,10 @@ packages: - vendor/* - packages/*/* + # The Landlock launcher is developed with its harness consumers but keeps + # its native build and publication scripts under native/landlock-run. + - native/landlock-run + - native/landlock-run/packages/* # Product assemblies over the package tier; apps/cli owns the `dsh` bin. - apps/* - website @@ -46,14 +50,7 @@ allowBuilds: # restores the executable bit on node-pty's macOS spawn helper. '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true -# The Landlock launcher family is our own sibling-repo release, consumed -# fresh (hours old at each coordinated bump) — the release-age quarantine -# would block every such bump, so the family is exempted BY NAME, not by -# pinned version. minimumReleaseAgeExclude: - - node-addon-landlock-run - - node-addon-landlock-run-linux-arm64 - - node-addon-landlock-run-linux-x64 # Cordis release candidates are source-vendored and pinned in vendor/README.md # during the same-day sync that updates package manifests and the lockfile. - '@cordisjs/plugin-loader@1.0.0-rc.5' diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 6bd613c2ea..32e2096aa9 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -15,6 +15,8 @@ const root = resolve(import.meta.dirname, '..') const workspaceGlobs = [ { dir: 'vendor', depth: 1 }, { dir: 'packages', depth: 2 }, + { dir: 'native', depth: 1 }, + { dir: 'native/landlock-run/packages', depth: 1 }, { dir: 'apps', depth: 1 }, ] as const const vendoredPackages = new Set([ @@ -28,6 +30,11 @@ const vendoredPackages = new Set([ '@cordisjs/plugin-hmr', '@cordisjs/plugin-logger-console', ]) +const publicLandlockPackages = new Set([ + 'node-addon-landlock-run', + 'node-addon-landlock-run-linux-arm64', + 'node-addon-landlock-run-linux-x64', +]) const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { @@ -55,6 +62,7 @@ interface PackageManifest { | undefined > files?: string[] + publishConfig?: { access?: string } peerDependencies?: Record devDependencies?: Record } @@ -71,6 +79,8 @@ function readJson(path: string): PackageManifest { const rootManifest = readJson(join(root, 'package.json')) const repositoryVersion = rootManifest.version +const landlockWorkspaceManifest = readJson(join(root, 'native/landlock-run/package.json')) +const landlockVersion = landlockWorkspaceManifest.version /** Repo-relative dirs holding a package.json, walked to the configured depth. */ function packageDirs(base: string, depth: number): string[] { @@ -161,8 +171,19 @@ function usesEmittedTreeDefaults(manifest: PackageManifest): boolean { function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { const errors: string[] = [] const label = manifest.name ?? dir + const isLandlockPackageDir = dir.startsWith('native/landlock-run/packages/') + const isPublicLandlockPackage = isLandlockPackageDir + && manifest.name !== undefined + && publicLandlockPackages.has(manifest.name) - if (manifest.private !== true) { + if (isPublicLandlockPackage) { + if (manifest.private === true) { + errors.push(`${label}: published Landlock package must not set "private": true`) + } + if (manifest.publishConfig?.access !== 'public') { + errors.push(`${label}: published Landlock package must set publishConfig.access to "public"`) + } + } else if (manifest.private !== true) { errors.push(`${label}: package.json must set "private": true`) } @@ -187,6 +208,15 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } } + if (isLandlockPackageDir) { + if (!isPublicLandlockPackage) { + errors.push(`${label}: unexpected package in the public Landlock package family`) + } + if (manifest.version !== landlockVersion) { + errors.push(`${label}: package.json version must match Landlock workspace version ${landlockVersion ?? '(missing)'}`) + } + } + if (dir.startsWith('packages/') && manifest.name?.startsWith('@deepseek-ai/dsh-')) { const peer = manifest.peerDependencies?.cordis const dev = manifest.devDependencies?.cordis diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts index 0a46764d9a..aada0667ef 100644 --- a/scripts/clean.spec.ts +++ b/scripts/clean.spec.ts @@ -18,10 +18,10 @@ function write(path: string, content = ''): void { writeFileSync(path, content) } -function addProject(root: string, path: string): void { +function addProject(root: string, path: string, outDir = 'lib/types'): void { write(join(root, 'tsconfig.json'), JSON.stringify({ files: [], references: [{ path }] })) write(join(root, path, 'tsconfig.json'), JSON.stringify({ - compilerOptions: { composite: true, outDir: 'lib/types' }, + compilerOptions: { composite: true, outDir }, include: ['src'], })) write(join(root, path, 'src/index.ts'), 'export {}\n') @@ -60,6 +60,18 @@ describe('RepositoryCleaner', () => { expect(existsSync(join(root, 'products/shell/lib'))).toBe(true) }) + it('removes the native Landlock entry output that emits directly to lib', async () => { + const root = fixture() + const entry = 'native/landlock-run/packages/entry' + addProject(root, entry, 'lib') + write(join(root, entry, 'lib/index.js')) + + await new RepositoryCleaner(root).clean() + + expect(existsSync(join(root, entry, 'lib'))).toBe(false) + expect(existsSync(join(root, entry, 'src/index.ts'))).toBe(true) + }) + it('refuses project outputs reached through a symlink outside the repository', async () => { const root = fixture() const externalProject = fixture() diff --git a/scripts/clean.ts b/scripts/clean.ts index fff158c458..1224fe8420 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -114,6 +114,7 @@ export class RepositoryCleaner { const outputs = new Set() const pending = [join(this.root, 'tsconfig.json')] const visited = new Set() + const nativeEntryOutput = join(this.root, 'native/landlock-run/packages/entry/lib') while (pending.length > 0) { const nextConfigPath = pending.pop() @@ -125,10 +126,14 @@ export class RepositoryCleaner { const parsed = parseConfig(configPath) if (parsed.options.outDir !== undefined) { const typesDirectory = resolve(parsed.options.outDir) - if (basename(typesDirectory) !== 'types') { + const outputDirectory = basename(typesDirectory) === 'types' + ? dirname(typesDirectory) + : typesDirectory === nativeEntryOutput + ? typesDirectory + : undefined + if (outputDirectory === undefined) { throw new Error(`clean: expected TypeScript outDir to end in /types: ${repositoryPath(this.root, typesDirectory)}`) } - const outputDirectory = dirname(typesDirectory) this.assertRepositoryTarget(outputDirectory) outputs.add(outputDirectory) } diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index f31cca6879..6db1f6ea8d 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -247,13 +247,13 @@ describe('isPermissive', () => { describe('manifestPatterns', () => { it('derives globs from the declared members, so a new member area is read', () => { - expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([ + expect(manifestPatterns(['packages/*/*', 'tools/*', 'native/landlock-run', 'native/landlock-run/packages/*'])).toEqual([ 'package.json', 'packages/*/*/package.json', 'tools/*/package.json', - 'examples/*/package.json', 'native/landlock-run/package.json', 'native/landlock-run/packages/*/package.json', + 'examples/*/package.json', ]) }) }) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 0d41953e4b..d7e0c26a5f 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -39,10 +39,7 @@ const DEV_ONLY_AREAS = [ 'native/', ] as const -/** - * First-party packages released from sibling repositories under the project's - * own license: reachable from workspace manifests but not third-party. - */ +/** First-party public native packages: reachable at runtime but not third-party. */ const FIRST_PARTY = new Set([ 'node-addon-landlock-run', 'node-addon-landlock-run-linux-arm64', @@ -119,16 +116,13 @@ function readManifest(rel: string): Manifest { * here, so a new member area (`tools/*`) is read the day it is declared. * @returns one glob per manifest-bearing location, repository-relative. */ -export function manifestPatterns(rootMembers: readonly string[], nativeMembers: readonly string[]): string[] { +export function manifestPatterns(rootMembers: readonly string[]): string[] { return [ 'package.json', ...rootMembers.map(member => `${member}/package.json`), // The demo leaves join the workspace through `examples/package.json`, so // their own manifests are members of nothing and no glob above reaches them. 'examples/*/package.json', - // `native/landlock-run` is a nested workspace with its own lock file. - 'native/landlock-run/package.json', - ...nativeMembers.map(member => `native/landlock-run/${member}/package.json`), ] } @@ -149,7 +143,7 @@ function workspaceMembers(rel: string): string[] { * would silently push dev-area manifests into the runtime tier. */ function loadWorkspaceManifests(): { manifests: Map; names: Set } { - const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml')) + const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml')) const manifests = new Map() const names = new Set() for (const pattern of patterns) { @@ -591,9 +585,9 @@ ${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.r | --- | --- | --- | ${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.license} | ${tool.role} |`).join('\n')} -## First-party sibling releases +## First-party native packages -\`node-addon-landlock-run\` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +\`node-addon-landlock-run\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. ` } diff --git a/tsconfig.base.json b/tsconfig.base.json index 9ba9ba5d84..634464cb9b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -38,6 +38,7 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], + "node-addon-landlock-run": ["./native/landlock-run/packages/entry/src/index.ts"], "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index c13d480a46..f6b125339a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -156,6 +156,7 @@ { "path": "./packages/bash/bash-env" }, { "path": "./packages/bash/pwsh-local" }, { "path": "./packages/bash/tool-pwsh" }, + { "path": "./native/landlock-run/packages/entry" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, { "path": "./packages/sandbox/sandbox-policy" }, From d3aa337c26806d14e45faf1319bb3d2ceada6cd5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 10:52:46 +0800 Subject: [PATCH 006/100] fix(landlock-run): close release integration gaps (review round 2) --- ...2026-07-27-dependabot-version-updates.i18n.yaml | 4 ++-- .../2026-07-27-dependabot-version-updates.md | 10 +++++----- .../2026-07-27-dependabot-version-updates.zh.md | 10 +++++----- ...6-07-30-generated-third-party-notices.i18n.yaml | 4 ++-- .../2026-07-30-generated-third-party-notices.md | 2 +- .../2026-07-30-generated-third-party-notices.zh.md | 2 +- .github/dependabot.yml | 14 -------------- THIRD_PARTY_NOTICES.md | 2 +- native/landlock-run/packages/entry/package.json | 5 +++++ .../landlock-run/packages/linux-arm64/package.json | 5 +++++ .../landlock-run/packages/linux-x64/package.json | 5 +++++ scripts/check-workspace-constraints.ts | 12 ++++++++++-- scripts/gen-third-party-notices.ts | 8 ++++---- 13 files changed, 46 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml index 07c742c518..316c31771e 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md -2026-07-27-dependabot-version-updates.md: 725649652c5b91ba4897d03b548b9aa5c3694c21 -2026-07-27-dependabot-version-updates.zh.md: 6400ba8ed94bf138fcece90e5d7ff82886d33ed1 +2026-07-27-dependabot-version-updates.md: 5d42563788d9f1e72da65c8e9750d6b1ecba06a5 +2026-07-27-dependabot-version-updates.zh.md: 4847059944e7e35de5719a6cbfd3d5b133467ccb diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md index 725649652c..5d42563788 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md @@ -6,23 +6,23 @@ English | [中文](2026-07-27-dependabot-version-updates.zh.md) ## Problem -Maintained registry and GitHub Actions dependencies need a regular update path. Adopting every release immediately increases exposure to compromised releases and early regressions, while leaving updates entirely manual lets dependency drift accumulate. Vendored Cordis sources and independently locked workspaces also cannot be treated as one undifferentiated package tree. +Maintained registry and GitHub Actions dependencies need a regular update path. Adopting every release immediately increases exposure to compromised releases and early regressions, while leaving updates entirely manual lets dependency drift accumulate. Vendored Cordis sources cannot be treated like registry dependencies, and workspaces sharing one lockfile must be updated through the same package tree. ## Decision -The default branch carries [`.github/dependabot.yml`](../../../../.github/dependabot.yml) with weekly version-update checks for the root pnpm workspace, the independently locked `native/landlock-run` pnpm workspace, the `python/sdk` uv project, and GitHub Actions. Every entry sets `cooldown.default-days` to `30`, so a version release becomes eligible only after it is at least 30 days old and is proposed on the next weekly check. +The default branch carries [`.github/dependabot.yml`](../../../../.github/dependabot.yml) with weekly version-update checks for the root pnpm workspace, including `native/landlock-run`, the `python/sdk` uv project, and GitHub Actions. Every entry sets `cooldown.default-days` to `30`, so a version release becomes eligible only after it is at least 30 days old and is proposed on the next weekly check. The [in-repository Landlock release decision](2026-08-06-in-repository-landlock-release.md) owns the shared-workspace boundary. -The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md), and `native/landlock-run/**`, which its dedicated entry owns. GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. +The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md). GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. Repository settings enable dependency vulnerability alerts and Dependabot security updates. GitHub does not apply version-update cooldowns to those security updates, so security fixes remain eligible immediately. A generated pnpm security pull request can still fail the repository's lockfile release-age verification when dependency resolution selects unrelated fresh transitive versions; that pull request waits or is narrowed instead of weakening the policy. The repository's coordinated fresh-release exceptions are not copied into Dependabot's cooldown exclusions: automated version updates use the uniform 30-day wait, while an explicitly reviewed manual update can still follow its owning release procedure. -The pnpm entries keep both workspaces on their pinned pnpm 11 instead of introducing an automation-only downgrade. The current Dependabot updater installs the version requested by `packageManager` and reads both workspaces' lockfile format `9.0`; the provider-run update job remains the integration check. +The pnpm entry keeps the unified workspace on its pinned pnpm 11 instead of introducing an automation-only downgrade. The current Dependabot updater installs the version requested by the root `packageManager` and reads the root lockfile format `9.0`; the provider-run update job remains the integration check. ## Alternatives considered - **Immediate version updates.** Rejected because they remove the requested release-age quarantine and make the project an early consumer of every upstream release. - **Automatic merging after CI.** Rejected because dependency changes can alter runtime, build, and release behavior; the normal review decision remains part of accepting an update. -- **One recursive npm scan.** Rejected because it could admit vendored manifests or conflate the root and native lockfiles. Explicit exclusions and a dedicated native entry preserve their ownership boundaries. +- **A separate native npm scan.** Rejected because the Landlock manifests belong to the root workspace and lockfile; splitting their update would recreate an ownership boundary the package manager no longer has. The root scan excludes only vendored manifests. - **Renovate or a scheduled agent.** Both can propose aged updates, but Dependabot is the requested service and the repository's CI already recognizes its pull requests as an untrusted dependency source. - **Cooldown exemptions for coordinated fresh releases.** Rejected for the automated path because those releases require an explicit synchronization or model-catalog decision rather than a generic update proposal. diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md index 6400ba8ed9..4847059944 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md @@ -6,23 +6,23 @@ Status: implemented ## 问题 -来自包注册表的依赖与 GitHub Actions 依赖都需要定期更新机制。每个新版本一经发布便立即采用,会增加受到遭入侵的版本和早期回归影响的风险;但完全依靠手动更新,又会导致依赖版本差距持续扩大。以源码形式纳入仓库的 Cordis 与各自维护独立锁文件的工作区,也不能不加区分地视为同一棵包树。 +来自包注册表的依赖与 GitHub Actions 依赖都需要定期更新机制。每个新版本一经发布便立即采用,会增加受到遭入侵的版本和早期回归影响的风险;但完全依靠手动更新,又会导致依赖版本差距持续扩大。以源码形式纳入仓库的 Cordis 不能当作注册表依赖处理,而共用一份锁文件的工作区必须通过同一棵包树更新。 ## 决策 -默认分支包含 [`.github/dependabot.yml`](../../../../.github/dependabot.yml),其中为根 pnpm 工作区、独立维护锁文件的 `native/landlock-run` pnpm 工作区、`python/sdk` uv 项目和 GitHub Actions 配置了每周一次的版本更新检查。每个更新项都将 `cooldown.default-days` 设为 `30`,因此某个版本只有在发布至少 30 天后才符合更新条件,并会在下一次每周检查时生成更新提案。 +默认分支包含 [`.github/dependabot.yml`](../../../../.github/dependabot.yml),其中为包含 `native/landlock-run` 的根 pnpm 工作区、`python/sdk` uv 项目和 GitHub Actions 配置了每周一次的版本更新检查。每个更新项都将 `cooldown.default-days` 设为 `30`,因此某个版本只有在发布至少 30 天后才符合更新条件,并会在下一次每周检查时生成更新提案。[仓库内 Landlock 发布决策](2026-08-06-in-repository-landlock-release.md)负责共享工作区边界。 -根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更;扫描还排除由专用更新项负责的 `native/landlock-run/**`。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 +根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 仓库设置已启用依赖项漏洞警报和 Dependabot 安全更新。GitHub 不会对这些安全更新应用版本更新冷却期,因此安全修复仍可立即进入更新流程。如果依赖解析还选中了其他刚发布的传递依赖,pnpm 安全更新 PR 仍可能无法通过仓库的锁文件发布时长校验;此类 PR 应等待隔离期结束或缩小更新范围,不得因此放宽政策。仓库为协调刚发布版本而设置的例外,不会纳入 Dependabot 的冷却期排除项:自动版本更新统一等待 30 天;经过明确评审的手动更新仍可遵循相应的发布流程。 -pnpm 更新项让两个工作区继续使用已固定的 pnpm 11,不会仅为了自动化而降级版本。当前 Dependabot 更新器会安装 `packageManager` 指定的版本,并读取两个工作区使用的 `9.0` 锁文件格式;由提供方运行的更新任务仍作为集成检查。 +pnpm 更新项让统一工作区继续使用已固定的 pnpm 11,不会仅为了自动化而降级版本。当前 Dependabot 更新器会安装根 `packageManager` 指定的版本,并读取根锁文件的 `9.0` 格式;由提供方运行的更新任务仍作为集成检查。 ## 考虑过的替代方案 - **立即进行版本更新。** 不采用,因为这会取消所要求的版本发布后隔离期,使项目在每个上游版本的发布初期就采用该版本。 - **CI 通过后自动合并。** 不采用,因为依赖变更可能改变运行时、构建和发布行为;是否接受更新仍须经过常规评审决策。 -- **使用一次递归 npm 扫描。** 不采用,因为它可能将随源码纳入仓库的 manifest 纳入更新范围,或混淆根工作区与 native 工作区的锁文件。显式排除项和专用 native 更新项可维持各自的归属边界。 +- **为 native 配置独立的 npm 扫描。** 不采用,因为 Landlock manifest 属于根工作区和根锁文件;拆分更新会重建一个包管理器已不存在的归属边界。根扫描仅排除随源码纳入的 manifest。 - **Renovate 或定期运行的 agent(智能体)。** 二者都能为发布已满一定时长的版本提出更新,但所要求的服务是 Dependabot,而且仓库 CI 已将其 PR 视为不可信的依赖来源。 - **为需协调的刚发布版本设置冷却期豁免。** 自动化路径不采用,因为此类版本需要明确的同步决策或模型目录决策,不能由通用更新提案代替。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index d65dae2802..afe8cdba57 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043 -2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561 +2026-07-30-generated-third-party-notices.md: 6a95953bc551cb38ca1d9aaa51a2041deadd1b08 +2026-07-30-generated-third-party-notices.zh.md: 9d48d3b39de76d5f4fbc1e2e9593a933b08583c8 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index e480954d29..6a95953bc5 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -24,7 +24,7 @@ The file discloses **direct** dependencies only. The complete npm closure with p The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. `scripts/install.sh` installs the repository itself, so a user's `cordis.yml` can mount any plugin package; `@modelcontextprotocol/sdk` and the OpenTelemetry packages reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. -The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. +The manifest set is derived from the `packages:` members the root `pnpm-workspace.yaml` declares, including the Landlock workspace and its public packages, so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the root workspace's installed pnpm store and package-local link farms, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. ## Testing diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 78ba7250e7..9d48d3b39d 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -24,7 +24,7 @@ Status: implemented 运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` 可以挂载任何插件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 -清单集合由两个 `pnpm-workspace.yaml`——根工作区与嵌套的 Landlock 工作区——各自声明的 `packages:` 成员派生,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自已安装的 pnpm store,根 store 与 Landlock 工作区的 store 都会查;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 +清单集合由根 `pnpm-workspace.yaml` 声明的 `packages:` 成员派生,其中包括 Landlock 工作区及其公开包,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自根工作区已安装的 pnpm store 和包本地链接场;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 ## Testing diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 81052d7c08..524d7912e2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,20 +6,6 @@ updates: exclude-paths: # Vendored Cordis sources follow vendor/README.md instead of registry updates. - "vendor/**" - # This independently locked pnpm workspace has its own update entry below. - - "native/landlock-run/**" - schedule: - interval: "cron" - cronjob: "0 4 * * *" - timezone: "Asia/Shanghai" - cooldown: - default-days: 30 - labels: - - "cleanup" - - "area/infra" - - - package-ecosystem: "npm" - directory: "/native/landlock-run" schedule: interval: "cron" cronjob: "0 4 * * *" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index aa73dba8d9..6ff3f6f8ea 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,7 +7,7 @@ DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the th This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. -The complete npm transitive closure, with exact pinned versions, is recorded in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded in [`python/sdk/uv.lock`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [`native/landlock-run/pnpm-lock.yaml`](native/landlock-run/pnpm-lock.yaml). +The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). ## Vendored source (`vendor/`) diff --git a/native/landlock-run/packages/entry/package.json b/native/landlock-run/packages/entry/package.json index f05e81f06b..56345b2847 100644 --- a/native/landlock-run/packages/entry/package.json +++ b/native/landlock-run/packages/entry/package.json @@ -3,6 +3,11 @@ "version": "0.0.1", "type": "module", "description": "Landlock self-restrict-then-exec launcher for sandboxing subprocesses on Linux: per-platform prebuilt static binaries plus the JS seam that resolves, probes, and speaks their CLI contract", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/landlock-run/packages/entry" + }, "main": "lib/index.js", "types": "lib/index.d.ts", "exports": { diff --git a/native/landlock-run/packages/linux-arm64/package.json b/native/landlock-run/packages/linux-arm64/package.json index 0067f77c8b..af5467cead 100644 --- a/native/landlock-run/packages/linux-arm64/package.json +++ b/native/landlock-run/packages/linux-arm64/package.json @@ -2,6 +2,11 @@ "name": "node-addon-landlock-run-linux-arm64", "version": "0.0.1", "description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/landlock-run/packages/linux-arm64" + }, "os": [ "linux" ], diff --git a/native/landlock-run/packages/linux-x64/package.json b/native/landlock-run/packages/linux-x64/package.json index 8ea60b636c..375d05332a 100644 --- a/native/landlock-run/packages/linux-x64/package.json +++ b/native/landlock-run/packages/linux-x64/package.json @@ -2,6 +2,11 @@ "name": "node-addon-landlock-run-linux-x64", "version": "0.0.1", "description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/landlock-run/packages/linux-x64" + }, "os": [ "linux" ], diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 32e2096aa9..0a446d77f8 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -35,6 +35,7 @@ const publicLandlockPackages = new Set([ 'node-addon-landlock-run-linux-arm64', 'node-addon-landlock-run-linux-x64', ]) +const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness.git' const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { @@ -63,6 +64,7 @@ interface PackageManifest { > files?: string[] publishConfig?: { access?: string } + repository?: { type?: string; url?: string; directory?: string } peerDependencies?: Record devDependencies?: Record } @@ -89,12 +91,12 @@ function packageDirs(base: string, depth: number): string[] { .filter(entry => entry.isDirectory()) .filter(entry => !localArtifactDirs.has(entry.name)) .filter(entry => existsSync(join(root, base, entry.name, 'package.json'))) - .map(entry => join(base, entry.name)) + .map(entry => `${base}/${entry.name}`) } return readdirSync(join(root, base), { withFileTypes: true }) .filter(entry => entry.isDirectory()) .filter(entry => !localArtifactDirs.has(entry.name)) - .flatMap(group => packageDirs(join(base, group.name), depth - 1)) + .flatMap(group => packageDirs(`${base}/${group.name}`, depth - 1)) } function workspaceManifests(): WorkspaceManifest[] { @@ -183,6 +185,12 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.publishConfig?.access !== 'public') { errors.push(`${label}: published Landlock package must set publishConfig.access to "public"`) } + const expectedDirectory = dir + if (manifest.repository?.type !== 'git' + || manifest.repository.url !== repositoryUrl + || manifest.repository.directory !== expectedDirectory) { + errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`) + } } else if (manifest.private !== true) { errors.push(`${label}: package.json must set "private": true`) } diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index d7e0c26a5f..e56cce670a 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -191,8 +191,8 @@ export function virtualManifest(virtual: string, name: string): VirtualManifest function installedMetadata(name: string): { license: string; repo: string } { const override = OVERRIDES[name] let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined - // The nested Landlock workspace installs into its own store, so a package - // only that workspace depends on is unreachable from the root one. + // Workspace-local link farms can expose a dependency that is not linked at + // the repository root; both are backed by the root workspace's lockfile. for (const store of ['node_modules', 'native/landlock-run/node_modules']) { const direct = resolve(root, store, name, 'package.json') if (existsSync(direct)) { @@ -208,7 +208,7 @@ function installedMetadata(name: string): { license: string; repo: string } { const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage const repo = override?.repo ?? normalizeRepo(rawRepo) if (license === undefined || repo === undefined) { - throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\` (or, for a Landlock-only dependency, \`pnpm --dir native/landlock-run install\`), or add an OVERRIDES entry.`) + throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\`, or add an OVERRIDES entry.`) } return { license, repo } } @@ -544,7 +544,7 @@ DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the th This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. -The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml). +The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded separately in [\`python/sdk/uv.lock\`](python/sdk/uv.lock). ## Vendored source (\`vendor/\`) From 32864c026cbfc7a36d875d1bee87b8c28bba0a78 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 11:11:57 +0800 Subject: [PATCH 007/100] fix(clean): remove native build state (review round 3) --- scripts/clean.spec.ts | 4 +++- scripts/clean.ts | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts index aada0667ef..c724453283 100644 --- a/scripts/clean.spec.ts +++ b/scripts/clean.spec.ts @@ -60,16 +60,18 @@ describe('RepositoryCleaner', () => { expect(existsSync(join(root, 'products/shell/lib'))).toBe(true) }) - it('removes the native Landlock entry output that emits directly to lib', async () => { + it('removes the native Landlock entry output and solution build info', async () => { const root = fixture() const entry = 'native/landlock-run/packages/entry' addProject(root, entry, 'lib') write(join(root, entry, 'lib/index.js')) + write(join(root, 'native/landlock-run/tsconfig.tsbuildinfo')) await new RepositoryCleaner(root).clean() expect(existsSync(join(root, entry, 'lib'))).toBe(false) expect(existsSync(join(root, entry, 'src/index.ts'))).toBe(true) + expect(existsSync(join(root, 'native/landlock-run/tsconfig.tsbuildinfo'))).toBe(false) }) it('refuses project outputs reached through a symlink outside the repository', async () => { diff --git a/scripts/clean.ts b/scripts/clean.ts index 1224fe8420..68e4ff4e71 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -72,6 +72,11 @@ export class RepositoryCleaner { for (const entry of await readdir(this.root, { withFileTypes: true })) { if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name)) } + await this.addIfPresent( + targets, + join(this.root, 'native/landlock-run/tsconfig.tsbuildinfo'), + canonicalRoot, + ) // The root project-reference graph is the source of truth for live build targets. // Each emitting project declares lib/types as outDir; its parent lib also owns From 10c1d77a4f3842812293d138a4e447356149ab5b Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 13:50:48 +0800 Subject: [PATCH 008/100] fix(landlock-run): address release review feedback --- .../implemented/feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-07-06-sandbox.md | 2 +- .../notes/implemented/feature/2026-07-06-sandbox.zh.md | 2 +- .github/workflows/landlock-run-release.yml | 10 ++++++++-- native/landlock-run/docs/release.md | 2 +- packages/bash/bash-sandbox/tests/landlock.e2e.ts | 6 +++--- .../sandbox/sandbox-local/tests/packed-install.e2e.ts | 6 ++++-- 7 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 5f8dfa4e65..b927bf9f72 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 69a3f1bd181bc06d9a176fa45b1e091991cfa682 -2026-07-06-sandbox.zh.md: eeca55b61da24df215f7a9b7ba8dbf9ab2387f20 +2026-07-06-sandbox.md: de00453eace87ef89e7e05bfe20e1ff956ee4d19 +2026-07-06-sandbox.zh.md: db84e9b3872fb5807720c75310c9ee58f2b9fdb7 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 69a3f1bd18..de00453eac 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from the main repository under `native/` following the `node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index eeca55b61d..db84e9b387 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 `native/` 下按 `node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 diff --git a/.github/workflows/landlock-run-release.yml b/.github/workflows/landlock-run-release.yml index dca6c9eed1..c69ebbe5ee 100644 --- a/.github/workflows/landlock-run-release.yml +++ b/.github/workflows/landlock-run-release.yml @@ -158,6 +158,14 @@ jobs: name: npm-tarballs path: native/landlock-run/dist/npm + - name: Configure npm token fallback + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [[ -n "$NPM_TOKEN" ]]; then + echo "NODE_AUTH_TOKEN=$NPM_TOKEN" >> "$GITHUB_ENV" + fi + - name: Publish tarballs run: | version="${GITHUB_REF#refs/tags/landlock-run-v}" @@ -166,5 +174,3 @@ jobs: while IFS= read -r tarball; do npm publish "dist/npm/${tarball}" --access public "${tag_args[@]}" done < dist/npm/publish-order.txt - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/native/landlock-run/docs/release.md b/native/landlock-run/docs/release.md index a95cffd47f..353a489c2c 100644 --- a/native/landlock-run/docs/release.md +++ b/native/landlock-run/docs/release.md @@ -45,7 +45,7 @@ Use the main repository's `Landlock Run Release` workflow so every binary is bui 2. Create and push the `landlock-run-vX.Y.Z` tag matching the package versions. 3. Run the same workflow from that tag with `publish=true`. -The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). It supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. +The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). A current-platform rehearsal can still query npm for metadata about an incompatible optional platform package; that package cannot supply the host launcher, which comes from the matching local tarball. Publishing every platform package before the entry ensures a public entry version never points ahead of its platform packages. The workflow supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)): diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 0c5cfbe563..7579292fe1 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -13,14 +13,14 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap - * rung forced off, so the npm-distributed `landlock-run` confines) underneath the + * rung forced off, so the workspace `landlock-run` launcher confines) underneath the * REAL `SandboxBashExecutor`, driven through the executor's public run/start * paths. Verifies the WORLD (files exist or don't) plus the stamped result * facts; the backend-only confinement proofs live with * `@deepseek-ai/dsh-sandbox-local`. * - * Self-skips when the running kernel does not enforce Landlock; the - * launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`). + * Self-skips when the running kernel does not enforce Landlock. CI builds the launcher from + * `native/landlock-run` before running this file. */ const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' }) diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index caf0a32c68..612751e6da 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -9,8 +9,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' /** * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current * repository's Landlock entry/platform packages, then installs those exact tarballs in an external - * plain-Node consumer. No registry copy, tsx, path mapping, or workspace resolution can hide - * missing files, dependency errors, or lost executable modes. + * plain-Node consumer. The host launcher comes from the exact local tarballs, so no registry copy, + * tsx, path mapping, or workspace resolution can hide missing files, dependency errors, or lost + * executable modes. npm may still query registry metadata for an incompatible optional platform + * package that cannot supply the host launcher. * * The installed launcher must match the host architecture, remain executable, and either confine a * real process with bwrap disabled or fail closed on a non-enforcing kernel. Skips off Linux or From 22c70870742bd69590863c769a5beee684bf8e77 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 14:41:17 +0800 Subject: [PATCH 009/100] fix(landlock-run): publish under deepseek scope --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-06-sandbox.md | 2 +- .../feature/2026-07-06-sandbox.zh.md | 2 +- ...2-win32-in-process-folder-dialog.i18n.yaml | 4 ++-- ...26-08-02-win32-in-process-folder-dialog.md | 2 +- ...08-02-win32-in-process-folder-dialog.zh.md | 2 +- ...6-in-repository-landlock-release.i18n.yaml | 4 ++-- ...26-08-06-in-repository-landlock-release.md | 14 ++++++----- ...08-06-in-repository-landlock-release.zh.md | 14 ++++++----- .github/workflows/landlock-run-release.yml | 6 ++--- .github/workflows/landlock-run.yml | 4 ++-- AGENTS.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- native/landlock-run/README.i18n.yaml | 4 ++-- native/landlock-run/README.md | 12 +++++----- native/landlock-run/README.zh.md | 12 +++++----- native/landlock-run/docs/architecture.md | 6 ++--- native/landlock-run/docs/naming.md | 6 ++--- native/landlock-run/docs/packaging.md | 6 ++--- native/landlock-run/docs/release.md | 2 ++ native/landlock-run/docs/support-matrix.md | 4 ++-- native/landlock-run/package.json | 4 ++-- .../packages/entry/README.i18n.yaml | 4 ++-- native/landlock-run/packages/entry/README.md | 6 ++--- .../landlock-run/packages/entry/README.zh.md | 6 ++--- .../landlock-run/packages/entry/package.json | 6 ++--- .../landlock-run/packages/entry/src/index.ts | 4 ++-- native/landlock-run/packages/entry/src/main.c | 2 +- .../packages/linux-arm64/README.i18n.yaml | 4 ++-- .../packages/linux-arm64/README.md | 6 ++--- .../packages/linux-arm64/README.zh.md | 6 ++--- .../packages/linux-arm64/package.json | 4 ++-- .../packages/linux-x64/README.i18n.yaml | 4 ++-- .../landlock-run/packages/linux-x64/README.md | 6 ++--- .../packages/linux-x64/README.zh.md | 6 ++--- .../packages/linux-x64/package.json | 4 ++-- .../scripts/verify-packed-install.mjs | 6 ++--- native/landlock-run/test/entry.test.js | 4 ++-- native/landlock-run/test/launcher.test.js | 2 +- packages/bash/bash-sandbox/package.json | 2 +- .../bash/bash-sandbox/tests/landlock.e2e.ts | 2 +- .../tests/partial-landlock.spec.ts | 2 +- .../examples/agent-spine-demo/package.json | 2 +- .../tests/multi-project-sandbox.e2e.ts | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-local/README.md | 2 +- packages/sandbox/sandbox-local/README.zh.md | 2 +- packages/sandbox/sandbox-local/package.json | 2 +- packages/sandbox/sandbox-local/src/index.ts | 2 +- .../sandbox/sandbox-local/src/profiles.ts | 2 +- .../sandbox-local/tests/landlock.e2e.ts | 2 +- .../sandbox/sandbox-local/tests/local.spec.ts | 2 +- .../sandbox-local/tests/packed-install.e2e.ts | 7 +++--- pnpm-lock.yaml | 24 +++++++++---------- scripts/check-workspace-constraints.ts | 13 ++++++---- scripts/gen-third-party-notices.ts | 8 +++---- tsconfig.base.json | 2 +- 57 files changed, 146 insertions(+), 134 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index b927bf9f72..7294f47357 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: de00453eace87ef89e7e05bfe20e1ff956ee4d19 -2026-07-06-sandbox.zh.md: db84e9b3872fb5807720c75310c9ee58f2b9fdb7 +2026-07-06-sandbox.md: 583b388815cd9b2b9cf94ce393839169ce3ffac3 +2026-07-06-sandbox.zh.md: e435b671a42ca5c3ea4f6800bf91d6e006da35d3 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index de00453eac..583b388815 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from the main repository under `native/` following the `node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from the main repository under `native/` following the `@deepseek-ai/node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index db84e9b387..e435b671a4 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 `native/` 下按 `node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 `native/` 下按 `@deepseek-ai/node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index 2ec7925a3e..b98c2bee56 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: 91a1ed0d7b1c1938a5e038ce36f1ca90bf3c9e82 -2026-08-02-win32-in-process-folder-dialog.zh.md: 6b90dc1c5fa0042b3e2bcbea8ed554f1f0ea2acf +2026-08-02-win32-in-process-folder-dialog.md: 5389293605169ce5ca269a127de5f609b8b7dd11 +2026-08-02-win32-in-process-folder-dialog.zh.md: ef81bc1f65b2859de1eb60f74f79e4869da4746b diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index 91a1ed0d7b..5389293605 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -14,7 +14,7 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Alternatives considered -- **A prebuilt native helper (`native/` family like `node-addon-landlock-run`).** Rejected: a mirror repository, an npm package family, MSVC provisioning, and a release handoff — all to ship ~150 lines of C the repository cannot exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. +- **A prebuilt native helper (`native/` family like `@deepseek-ai/node-addon-landlock-run`).** Rejected: another npm package family, MSVC provisioning, and a Windows build/release lane — all to ship ~150 lines of C the repository cannot currently exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. - **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that a child process + koffi express in TypeScript. - **Keep PowerShell primary and probe versions.** Rejected: the picker stays hostage to shell packaging (6 vs 7, Store aliases, profiles), and 5.1's legacy dialog remains the floor wherever pwsh is absent; the fallback-trigger widening alone was accepted into the fallback tier instead. - **Blocking the main thread for the modal call.** Rejected outright: the web host must keep serving RPC while the dialog is open. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index 6b90dc1c5f..ef81bc1f65 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -14,7 +14,7 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 考虑过的替代方案 -- **预编译原生助手(`native/` 家族,如 `node-addon-landlock-run`)。** 否决:镜像仓库、npm 包家族、MSVC 供给和发布交接——只为交付约 150 行 CI 无法执行的 C(没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 +- **预编译原生助手(`native/` 家族,如 `@deepseek-ai/node-addon-landlock-run`)。** 否决:再增加一个 npm 包家族、MSVC 供给和 Windows 构建/发布通道——只为交付约 150 行目前无法在 CI 中执行的 C(现有 CI 没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 - **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而子进程 + koffi 用 TypeScript 就能表达。 - **保留 PowerShell 为主层并探测版本。** 否决:选择器仍被 shell 打包形态挟持(6 与 7、Store 别名、profile),且没有 pwsh 的机器地板仍是 5.1 的旧版对话框;仅把回退触发条件的拓宽吸收进回退层。 - **在主线程上阻塞模态调用。** 直接否决:对话框打开期间 web 宿主必须继续服务 RPC。 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml index 3ce0e0d5e1..2a4a389174 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md -2026-08-06-in-repository-landlock-release.md: f682078250adde8d56a4270e9d01ce4b1cd1bee9 -2026-08-06-in-repository-landlock-release.zh.md: 4950d80d87afd18c5605f4f5bca56b8d85564fc2 +2026-08-06-in-repository-landlock-release.md: 3ae9e9c3c50a1d0202a345e419cb2b7079e29ffa +2026-08-06-in-repository-landlock-release.zh.md: 9f2233f6ae95620d7221f83648b5dc9b4cf8c7d2 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md index f682078250..3ae9e9c3c5 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md @@ -6,17 +6,19 @@ English | [中文](2026-08-06-in-repository-landlock-release.zh.md) ## Problem -The `node-addon-landlock-run` source already lives beside its DeepSeek Harness consumers under `native/landlock-run`, but it previously kept a separate pnpm workspace and lockfile and depended on a standalone repository for npm publication. Harness packages consumed a fixed registry version, so one pull request could change the launcher contract and its consumer without testing those changes together. The source repository's native workflow could rehearse the package, but it did not publish the artifact it tested. +The `@deepseek-ai/node-addon-landlock-run` source already lives beside its DeepSeek Harness consumers under `native/landlock-run`, but it previously kept a separate pnpm workspace and lockfile and depended on a standalone repository for npm publication. Harness packages consumed a fixed registry version, so one pull request could change the launcher contract and its consumer without testing those changes together. The source repository's native workflow could rehearse the package, but it did not publish the artifact it tested. The mirror also duplicated release coordination: export the source, update another lockfile, run another release workflow, publish the native family, then return to this repository to bump registry dependencies. That split made source-to-binary provenance, rollback, and security-fix coordination harder without changing what npm users actually needed. +The existing unscoped npm names are owned by the standalone publisher account rather than the `@deepseek-ai` organization. Moving only the workflow would therefore leave publication dependent on a personal credential outside the repository's release ownership. + The consolidation must preserve platform selection. The public distribution is deliberately one JavaScript entry package plus separate Linux x64 and arm64 binary packages; merging repository ownership does not imply putting every binary into one tarball or publishing every DeepSeek Harness package at the launcher version. ## Decision -`native/landlock-run` and `native/landlock-run/packages/*` belong to the repository's root pnpm workspace and use the root `pnpm-lock.yaml`. Harness consumers declare `node-addon-landlock-run` with `workspace:*`, so development, type checking, builds, and pull-request tests resolve the entry package from the same checkout. The root TypeScript project graph builds that entry package before consumers, and the repository cleaner owns its direct `lib/` output. +`native/landlock-run` and `native/landlock-run/packages/*` belong to the repository's root pnpm workspace and use the root `pnpm-lock.yaml`. Harness consumers declare `@deepseek-ai/node-addon-landlock-run` with `workspace:*`, so development, type checking, builds, and pull-request tests resolve the entry package from the same checkout. The root TypeScript project graph builds that entry package before consumers, and the repository cleaner owns its direct `lib/` output. -The public npm boundary remains three packages with one launcher-family version: `node-addon-landlock-run`, `node-addon-landlock-run-linux-x64`, and `node-addon-landlock-run-linux-arm64`. The entry package retains both platform packages as `optionalDependencies`; their `os` and `cpu` manifest fields let npm install only the compatible package. Repository constraints allow public publication only for those three names, require `publishConfig.access: public`, and require their versions to match the private launcher workspace root. Other repository workspaces remain private under the existing constraint. +The public npm boundary is three organization-owned packages with one launcher-family version: `@deepseek-ai/node-addon-landlock-run`, `@deepseek-ai/node-addon-landlock-run-linux-x64`, and `@deepseek-ai/node-addon-landlock-run-linux-arm64`. The entry package retains both platform packages as `optionalDependencies`; their `os` and `cpu` manifest fields let npm install only the compatible package. Repository constraints allow public publication only for those three names, require `publishConfig.access: public`, and require their versions to match the private launcher workspace root. The former unscoped names are not release targets of this repository; other repository workspaces remain private under the existing constraint. The main repository owns both native CI and publication. `Landlock Run` runs for relevant pull requests and `master` pushes and builds each platform on its matching native runner. The manually dispatched `Landlock Run Release` workflow builds both platform binaries, transfers them as workflow artifacts, assembles and verifies the complete package family, packs immutable npm tarballs, installs and exercises those tarballs, and only then permits the protected publish job. Platform tarballs publish before the entry tarball that optionally depends on them. Publication uses `landlock-run-vX.Y.Z` tags so launcher releases cannot collide with other release families in the monorepo; prereleases use the npm `next` dist-tag. @@ -26,17 +28,17 @@ The sandbox packed-install rehearsal no longer permits the npm registry to suppl - **Keep the standalone repository as a release mirror** — rejected because it preserves the split lockfiles, source export, stale-registry test window, and cross-repository release sequence after the source of record has already moved here. - **Publish one npm package containing every platform binary** — rejected because users would download binaries they cannot run and npm could no longer use package-level `os`/`cpu` filtering. Repository ownership and npm package layout are separate choices. -- **Give the launcher the root DeepSeek Harness version and publish the complete monorepo recursively** — rejected because this change owns one three-package public family, not the independent `@deepseek-ai/*` baseline. The [artifact-first npm baseline proposal](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md) explicitly keeps native workspaces outside its target set. +- **Give the launcher the root DeepSeek Harness version and publish the complete monorepo recursively** — rejected because this change owns one three-package public family, not the independent `@deepseek-ai/dsh-*` baseline. The [artifact-first npm baseline proposal](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md) explicitly keeps native workspaces outside its target set. - **Cross-compile both binaries in one release job** — rejected because the checked-in package matrix already assigns each architecture a native GitHub runner and avoids adding a cross-toolchain trust surface. ## Consequences Launcher protocol, TypeScript entry code, native source, harness consumption, and publish-path tests can change in one pull request and resolve from one lockfile. A release tag now identifies the source, consumer integration, build instructions, and tarballs tested by the main repository. The standalone mirror is no longer part of the release path and can be archived after the first successful in-repository publication. -npm consumers keep the same install command and package names. A supported Linux host downloads the entry package and its matching architecture package; the other architecture package is skipped. An unsupported host receives no platform binary and follows the existing deterministic fail-closed probe path. +npm consumers install `@deepseek-ai/node-addon-landlock-run`; the old unscoped package names are not silently redirected. A supported Linux host downloads the scoped entry package and its matching architecture package; the other architecture package is skipped. An unsupported host receives no platform binary and follows the existing deterministic fail-closed probe path. The implementation touches more files than a dependency-line edit because the repository must also own workspace constraints, TypeScript build order, cleanup, CI triggers, release tags, lockfile generation, packed-install provenance, release documentation, and generated notices. The behavioral boundary stays narrow: it changes only the Landlock package family and its three direct workspace consumers, not the version or publication state of other DeepSeek Harness packages. -The main repository's `npm-publish` environment must authorize npm trusted publishing or provide `NPM_TOKEN`; moving workflow code cannot configure those external settings. npm still publishes packages sequentially and offers no cross-package transaction, so a failed publish can leave a partial version. Because npm rejects an already-published name and version, an operator must inspect the registry and publish only the missing tarballs rather than rerunning the workflow unchanged. Linux x64 and arm64 runners remain the authoritative binary and real-kernel checks; a macOS checkout can verify the entry package and unsupported-platform behavior but cannot replace those jobs. +The first scoped release must use an `@deepseek-ai` organization token through the `npm-publish` environment's `NPM_TOKEN`, because npm cannot configure trusted publishing until a package exists. After bootstrap, all three packages must authorize this repository's release workflow before the fallback token can be removed. npm still publishes packages sequentially and offers no cross-package transaction, so a failed publish can leave a partial version. Because npm rejects an already-published name and version, an operator must inspect the registry and publish only the missing tarballs rather than rerunning the workflow unchanged. Linux x64 and arm64 runners remain the authoritative binary and real-kernel checks; a macOS checkout can verify the entry package and unsupported-platform behavior but cannot replace those jobs. This note supersedes only the release-mirror and registry-pinned source-development statements in the [sandbox Agent Note](../feature/2026-07-06-sandbox.md); that note continues to own sandbox behavior, runner selection, and enforcement semantics. diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md index 4950d80d87..9f2233f6ae 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md @@ -6,17 +6,19 @@ Status: implemented ## 问题 -`node-addon-landlock-run` 源码已经与其 DeepSeek Harness 消费方一同位于 `native/landlock-run` 下,但此前仍保留独立的 pnpm workspace 和锁文件,并依赖一个独立仓库发布到 npm。Harness 包使用 npm 注册表中的固定版本,因此同一个 PR(Pull Request)可以同时修改启动器契约及其消费方,却无法一起测试这些改动。源码仓库的原生工作流可以演练打包流程,但不会发布它实际测试过的产物。 +`@deepseek-ai/node-addon-landlock-run` 源码已经与其 DeepSeek Harness 消费方一同位于 `native/landlock-run` 下,但此前仍保留独立的 pnpm workspace 和锁文件,并依赖一个独立仓库发布到 npm。Harness 包使用 npm 注册表中的固定版本,因此同一个 PR(Pull Request)可以同时修改启动器契约及其消费方,却无法一起测试这些改动。源码仓库的原生工作流可以演练打包流程,但不会发布它实际测试过的产物。 发布镜像还造成重复的发布协调工作:导出源码、更新另一份锁文件、运行另一套发布工作流、发布原生包家族,然后回到本仓库更新注册表依赖。npm 用户的实际需求并未改变,这种拆分却增加了从源码到二进制的溯源、回滚和安全修复协调难度。 +现有的非 scoped npm 包名归独立发布账号所有,而不属于 `@deepseek-ai` 组织。因此,仅迁移工作流仍会让发布依赖仓库发布归属之外的个人凭证。 + 此次整合必须保留平台选择机制。公开分发有意采用一个 JavaScript 入口包,并为 Linux x64 和 arm64 分别提供二进制包;合并仓库归属并不意味着要把所有二进制文件放进同一个 tarball,也不意味着要按照启动器版本发布所有 DeepSeek Harness 包。 ## 决策 -`native/landlock-run` 和 `native/landlock-run/packages/*` 属于仓库根 pnpm workspace,并使用根 `pnpm-lock.yaml`。Harness 消费方将 `node-addon-landlock-run` 声明为 `workspace:*`,因此开发、类型检查、构建和 PR 测试都会从同一个 checkout 解析入口包。根 TypeScript 项目图会先构建该入口包,再构建消费方;仓库清理器负责清理其直接生成的 `lib/` 输出目录。 +`native/landlock-run` 和 `native/landlock-run/packages/*` 属于仓库根 pnpm workspace,并使用根 `pnpm-lock.yaml`。Harness 消费方将 `@deepseek-ai/node-addon-landlock-run` 声明为 `workspace:*`,因此开发、类型检查、构建和 PR 测试都会从同一个 checkout 解析入口包。根 TypeScript 项目图会先构建该入口包,再构建消费方;仓库清理器负责清理其直接生成的 `lib/` 输出目录。 -公开 npm 分发边界仍由 3 个包组成,它们共用一个启动器包家族版本:`node-addon-landlock-run`、`node-addon-landlock-run-linux-x64` 和 `node-addon-landlock-run-linux-arm64`。入口包继续通过 `optionalDependencies` 声明两个平台包;它们在 manifest(元数据清单)中的 `os` 和 `cpu` 字段让 npm 只安装兼容的包。仓库约束只允许公开发布这 3 个包名,要求设置 `publishConfig.access: public`,并要求其版本与私有启动器 workspace 根包一致。仓库中的其他 workspace 仍受现有约束保护,保持私有状态。 +公开 npm 分发边界由 3 个归组织所有的包组成,它们共用一个启动器包家族版本:`@deepseek-ai/node-addon-landlock-run`、`@deepseek-ai/node-addon-landlock-run-linux-x64` 和 `@deepseek-ai/node-addon-landlock-run-linux-arm64`。入口包继续通过 `optionalDependencies` 声明两个平台包;它们在 manifest(元数据清单)中的 `os` 和 `cpu` 字段让 npm 只安装兼容的包。仓库约束只允许公开发布这 3 个包名,要求设置 `publishConfig.access: public`,并要求其版本与私有启动器 workspace 根包一致。原先的非 scoped 包名不属于本仓库的发布目标;仓库中的其他 workspace 仍受现有约束保护,保持私有状态。 主仓库同时负责原生 CI 和发布。`Landlock Run` 会为相关 PR 和 `master` 推送运行,并在各自匹配的原生 runner 上构建每个平台包。手动触发的 `Landlock Run Release` 工作流会构建两个平台的二进制文件,将其作为工作流产物传递,组装并验证完整的包家族,打包出内容不可变的 npm tarball,安装并实际运行这些 tarball,之后才允许受保护的发布作业执行。发布顺序是平台 tarball 在前,最后发布将它们列为可选依赖的入口 tarball。发布使用 `landlock-run-vX.Y.Z` tag,避免启动器版本与 monorepo 中其他发布家族发生冲突;预发布版本使用 npm 的 `next` dist-tag。 @@ -26,17 +28,17 @@ Status: implemented - **保留独立仓库作为发布镜像**:不予采纳,因为在权威源码已经迁入本仓库后,这仍会保留拆分的锁文件、源码导出、测试使用陈旧注册表版本的时间窗,以及跨仓库发布序列。 - **发布一个包含所有平台二进制文件的 npm 包**:不予采纳,因为用户会下载无法在其主机上运行的二进制文件,而且 npm 无法再利用包级 `os`/`cpu` 筛选。仓库归属与 npm 包布局是两个彼此独立的选择。 -- **让启动器使用 DeepSeek Harness 根版本,并递归发布整个 monorepo**:不予采纳,因为本次改动负责的是一个由 3 个包组成的公开包家族,而不是独立的 `@deepseek-ai/*` 基线。[产物优先的 npm 基线提案](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md)明确将原生 workspace 排除在其目标集合之外。 +- **让启动器使用 DeepSeek Harness 根版本,并递归发布整个 monorepo**:不予采纳,因为本次改动负责的是一个由 3 个包组成的公开包家族,而不是独立的 `@deepseek-ai/dsh-*` 基线。[产物优先的 npm 基线提案](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md)明确将原生 workspace 排除在其目标集合之外。 - **在一个发布作业中交叉编译两个二进制文件**:不予采纳,因为仓库内已提交的包矩阵已经为每种架构分配了原生 GitHub runner,无需再把交叉工具链纳入信任边界。 ## 后果 同一个 PR 可以同时修改启动器协议、TypeScript 入口代码、原生源码、harness 消费方式和发布路径测试,并从同一份锁文件解析这些内容。发布 tag 现在标识源码、消费方集成、构建指令,以及主仓库测试过的 tarball。第一次成功从本仓库发布后,独立镜像便不再属于发布路径,可以归档。 -npm 消费方继续使用相同的安装命令和包名。受支持的 Linux 主机会下载入口包及与其架构匹配的包,并跳过另一架构的包。不受支持的主机不会收到平台二进制文件,并继续沿用现有的确定性失败闭合探测路径。 +npm 消费方改为安装 `@deepseek-ai/node-addon-landlock-run`;原先的非 scoped 包名不会被静默重定向。受支持的 Linux 主机会下载 scoped 入口包及与其架构匹配的包,并跳过另一架构的包。不受支持的主机不会收到平台二进制文件,并继续沿用现有的确定性失败闭合探测路径。 实现涉及的文件比只修改一行依赖更多,因为仓库还必须负责 workspace 约束、TypeScript 构建顺序、清理、CI 触发条件、发布 tag、锁文件生成、打包安装来源证明、发布文档和生成的第三方声明。行为边界仍然很窄:此次改动只影响 Landlock 包家族及其 3 个直接 workspace 消费方,不改变其他 DeepSeek Harness 包的版本或发布状态。 -主仓库的 `npm-publish` 环境必须授权 npm trusted publishing,或提供 `NPM_TOKEN`;只迁移工作流代码无法配置这些外部设置。npm 仍会按顺序发布各个包,且不提供跨包事务,因此发布失败可能留下只完成了一部分的版本。由于 npm 会拒绝已经发布的同名同版本包,操作人员必须检查注册表并只发布缺失的 tarball,而不能原样重新运行工作流。Linux x64 和 arm64 runner 仍提供权威的二进制构建与真实内核检查;macOS checkout 可以验证入口包和不受支持平台上的行为,但不能取代这些作业。 +第一次发布 scoped 包时,必须通过 `npm-publish` 环境的 `NPM_TOKEN` 使用 `@deepseek-ai` 组织 token,因为 npm 只有在包已经存在后才能配置 trusted publishing。完成 bootstrap 后,必须让 3 个包都授权本仓库的发布工作流,才能移除后备 token。npm 仍会按顺序发布各个包,且不提供跨包事务,因此发布失败可能留下只完成了一部分的版本。由于 npm 会拒绝已经发布的同名同版本包,操作人员必须检查注册表并只发布缺失的 tarball,而不能原样重新运行工作流。Linux x64 和 arm64 runner 仍提供权威的二进制构建与真实内核检查;macOS checkout 可以验证入口包和不受支持平台上的行为,但不能取代这些作业。 本说明仅取代[沙箱 Agent Note](../feature/2026-07-06-sandbox.md)中有关发布镜像和开发源码时依赖注册表固定版本的表述;该 Agent Note 仍负责沙箱行为、runner 选择和强制执行语义。 diff --git a/.github/workflows/landlock-run-release.yml b/.github/workflows/landlock-run-release.yml index c69ebbe5ee..7d78e98bc6 100644 --- a/.github/workflows/landlock-run-release.yml +++ b/.github/workflows/landlock-run-release.yml @@ -1,4 +1,4 @@ -# Build and publish the node-addon-landlock-run package family from the +# Build and publish the @deepseek-ai/node-addon-landlock-run package family from the # harness source of record. Rehearsal and publication consume the same packed # tarballs; each native binary is built on its matching architecture. name: Landlock Run Release @@ -58,7 +58,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - name: Install musl toolchain run: | @@ -97,7 +97,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - name: Build TypeScript run: pnpm build:ts diff --git a/.github/workflows/landlock-run.yml b/.github/workflows/landlock-run.yml index 391e1eeae1..6379c8cdc1 100644 --- a/.github/workflows/landlock-run.yml +++ b/.github/workflows/landlock-run.yml @@ -73,7 +73,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - name: Install musl toolchain run: | @@ -124,7 +124,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --filter node-addon-landlock-run-workspace... --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - name: Build TypeScript run: pnpm build:ts diff --git a/AGENTS.md b/AGENTS.md index a42f39084a..79d35f97fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// support/ dev/test infrastructure util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) -native/ node-addon-landlock-run source of record (see native/README.md) +native/ @deepseek-ai/node-addon-landlock-run source of record (see native/README.md) examples/ Runnable cordis.yml leaves over packages/examples bundles (see examples/AGENTS.md) .agents/ Agent workflows and Agent Notes (`notes/`) docs/ architecture, generated catalogs, postmortems, cookbook (see docs/AGENTS.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6ff3f6f8ea..e6907730fe 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -172,4 +172,4 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm ## First-party native packages -`node-addon-landlock-run` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +`@deepseek-ai/node-addon-landlock-run` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. diff --git a/native/landlock-run/README.i18n.yaml b/native/landlock-run/README.i18n.yaml index bdcf985216..c204d571cf 100644 --- a/native/landlock-run/README.i18n.yaml +++ b/native/landlock-run/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/landlock-run/README.md -README.md: 19cc18830b90609f648cfb2ce1ee509ad9fe381b -README.zh.md: 5d3c1c2cd692bb87d5a759a0a6f9628a3f065863 +README.md: fcb8e8249e6728d925fd08c938a780b155d3d4ac +README.zh.md: 8206ab8074d8d80fb9b11cff436bbd10e6dc34dd diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md index 19cc18830b..fcb8e8249e 100644 --- a/native/landlock-run/README.md +++ b/native/landlock-run/README.md @@ -1,4 +1,4 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run English | [中文](README.zh.md) @@ -9,15 +9,15 @@ The first tool is **`landlock-run`** — a self-restrict-then-exec [Landlock](ht ## Install ```sh -npm install node-addon-landlock-run +npm install @deepseek-ai/node-addon-landlock-run ``` Published packages use an entry package plus platform optional packages: ```text -node-addon-landlock-run -node-addon-landlock-run-linux-x64 -node-addon-landlock-run-linux-arm64 +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run-linux-x64 +@deepseek-ai/node-addon-landlock-run-linux-arm64 ``` npm's `os`/`cpu` fields make installers fetch only the matching platform package. There is no install-time build fallback on purpose: on a host without a platform package the resolved path never exists, the probe reports `unusable`, and the consumer falls closed. @@ -25,7 +25,7 @@ npm's `os`/`cpu` fields make installers fetch only the matching platform package ## Usage ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md index 5d3c1c2cd6..8206ab8074 100644 --- a/native/landlock-run/README.zh.md +++ b/native/landlock-run/README.zh.md @@ -1,4 +1,4 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run [English](README.md) | 中文 @@ -9,15 +9,15 @@ ## 安装 ```sh -npm install node-addon-landlock-run +npm install @deepseek-ai/node-addon-landlock-run ``` 已发布包由一个入口包和可选平台包组成: ```text -node-addon-landlock-run -node-addon-landlock-run-linux-x64 -node-addon-landlock-run-linux-arm64 +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run-linux-x64 +@deepseek-ai/node-addon-landlock-run-linux-arm64 ``` npm 的 `os`/`cpu` 字段使安装器只拉取匹配的平台包。系统有意不提供安装时构建回退:在没有对应平台包的宿主上,解析后的路径绝不存在,探测会报告 `unusable`,消费方以失败闭合方式处理。 @@ -25,7 +25,7 @@ npm 的 `os`/`cpu` 字段使安装器只拉取匹配的平台包。系统有意 ## 用法 ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { diff --git a/native/landlock-run/docs/architecture.md b/native/landlock-run/docs/architecture.md index e6974f4e50..b462b40635 100644 --- a/native/landlock-run/docs/architecture.md +++ b/native/landlock-run/docs/architecture.md @@ -6,8 +6,8 @@ This repository owns confinement *mechanism*, not policy: consumers (agent harne The family is one entry package plus per-platform binary packages: -- **Entry package** (`node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. -- **Platform packages** (`node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. +- **Entry package** (`@deepseek-ai/node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. +- **Platform packages** (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. Because the contract parser and the binary version together in one family, probe-parsing drift against the binary is structurally impossible — the failure mode the split exists to prevent. @@ -15,7 +15,7 @@ There is no shared loader package: platform packages have nothing to load. If a ## Resolution and availability -`launcherPath()` resolves `node-addon-landlock-run--` and returns `/bin/landlock-run`. When the package is not resolvable it returns a deterministic fallback path inside the entry package's own `node_modules` that simply never exists. Existence is deliberately unchecked either way: `probe()` is the single availability signal, and a missing binary probes `unusable` exactly like an unenforcing kernel. Consumers get one degradation path, not two. +`launcherPath()` resolves `@deepseek-ai/node-addon-landlock-run--` and returns `/bin/landlock-run`. When the package is not resolvable it returns a deterministic fallback path inside the entry package's own `node_modules` that simply never exists. Existence is deliberately unchecked either way: `probe()` is the single availability signal, and a missing binary probes `unusable` exactly like an unenforcing kernel. Consumers get one degradation path, not two. The probe is functional — the launcher builds and enforces a real maximal ruleset in a short-lived child — because version checks would miss a kernel that has the syscalls but refuses enforcement. diff --git a/native/landlock-run/docs/naming.md b/native/landlock-run/docs/naming.md index 9de9f0b95f..a1f2665654 100644 --- a/native/landlock-run/docs/naming.md +++ b/native/landlock-run/docs/naming.md @@ -2,11 +2,11 @@ ## npm packages -The public package family is unscoped, using the `node-addon-landlock-run` package prefix; platform packages append platform information only: +The public package family belongs to the `@deepseek-ai` scope and uses the `node-addon-landlock-run` package prefix; platform packages append platform information only: ```text -node-addon-landlock-run -node-addon-landlock-run- +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run- ``` Platform suffixes carry no libc component (binaries are static musl) and no variant component — variants stay inside `prebuilds.json` and binary filenames. diff --git a/native/landlock-run/docs/packaging.md b/native/landlock-run/docs/packaging.md index 9a1be47b2a..ec459eb655 100644 --- a/native/landlock-run/docs/packaging.md +++ b/native/landlock-run/docs/packaging.md @@ -5,9 +5,9 @@ The package family uses the same broad shape as native packages such as esbuild: ## Published packages ```text -node-addon-landlock-run -node-addon-landlock-run-linux-x64 -node-addon-landlock-run-linux-arm64 +@deepseek-ai/node-addon-landlock-run +@deepseek-ai/node-addon-landlock-run-linux-x64 +@deepseek-ai/node-addon-landlock-run-linux-arm64 ``` Unsupported platforms are intentionally absent from `optionalDependencies` — see [support-matrix.md](support-matrix.md). diff --git a/native/landlock-run/docs/release.md b/native/landlock-run/docs/release.md index 353a489c2c..d5eec50b6e 100644 --- a/native/landlock-run/docs/release.md +++ b/native/landlock-run/docs/release.md @@ -47,6 +47,8 @@ Use the main repository's `Landlock Run Release` workflow so every binary is bui The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). A current-platform rehearsal can still query npm for metadata about an incompatible optional platform package; that package cannot supply the host launcher, which comes from the matching local tarball. Publishing every platform package before the entry ensures a public entry version never points ahead of its platform packages. The workflow supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. +The three scoped package names must be bootstrapped with an `@deepseek-ai` organization token through the `NPM_TOKEN` fallback: npm [requires a package to exist before a trusted publisher can be configured](https://docs.npmjs.com/cli/v11/commands/npm-trust/). After the first release creates all three packages, configure each package to trust `landlock-run-release.yml` in this repository with the `npm-publish` environment, then remove the fallback token when organization policy permits it. + Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)): ```sh diff --git a/native/landlock-run/docs/support-matrix.md b/native/landlock-run/docs/support-matrix.md index 96d02b3cf6..e60ad201c1 100644 --- a/native/landlock-run/docs/support-matrix.md +++ b/native/landlock-run/docs/support-matrix.md @@ -4,8 +4,8 @@ | Platform package | GitHub runner (builder of record) | Notes | |---|---|---| -| `node-addon-landlock-run-linux-x64` | `ubuntu-24.04` | static musl — glibc and musl distros alike | -| `node-addon-landlock-run-linux-arm64` | `ubuntu-24.04-arm` | static musl — glibc and musl distros alike | +| `@deepseek-ai/node-addon-landlock-run-linux-x64` | `ubuntu-24.04` | static musl — glibc and musl distros alike | +| `@deepseek-ai/node-addon-landlock-run-linux-arm64` | `ubuntu-24.04-arm` | static musl — glibc and musl distros alike | Enforcement additionally requires a kernel with Landlock enabled (5.13+). The negotiated ABI level decides the probe verdict: every access this build knows governed → `full`; an older ABI governing a subset → `partial` (still confined for everything it supports); Landlock absent or disabled → `unusable`, and the launcher refuses to run commands at all. The probe — not the kernel version — is the authority: a kernel built without Landlock, or with the LSM disabled, probes `unusable` regardless of its version. diff --git a/native/landlock-run/package.json b/native/landlock-run/package.json index 6fe3f7eff9..0fb9b3ddfb 100644 --- a/native/landlock-run/package.json +++ b/native/landlock-run/package.json @@ -1,5 +1,5 @@ { - "name": "node-addon-landlock-run-workspace", + "name": "@deepseek-ai/node-addon-landlock-run-workspace", "version": "0.0.1", "private": true, "type": "module", @@ -22,7 +22,7 @@ "release:verify-packed-install": "node ./scripts/verify-packed-install.mjs" }, "devDependencies": { - "node-addon-landlock-run": "workspace:*", + "@deepseek-ai/node-addon-landlock-run": "workspace:*", "@types/node": "^26.0.1", "tsx": "^4.20.6", "typescript": "^6.0.3" diff --git a/native/landlock-run/packages/entry/README.i18n.yaml b/native/landlock-run/packages/entry/README.i18n.yaml index 47d33e0d70..7c67f49670 100644 --- a/native/landlock-run/packages/entry/README.i18n.yaml +++ b/native/landlock-run/packages/entry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/landlock-run/packages/entry/README.md -README.md: e402cdfe71c4eb81b977a21955fe3fff6bf55fd3 -README.zh.md: e4fcd33a256b51c815cdd1c6771be328bc46f138 +README.md: fff722428c5d213d9fcce0ee87a1d48cdc189884 +README.zh.md: f462fbe3cb0cb8d1d83b4d6b1d8e2f61e88ff69c diff --git a/native/landlock-run/packages/entry/README.md b/native/landlock-run/packages/entry/README.md index e402cdfe71..fff722428c 100644 --- a/native/landlock-run/packages/entry/README.md +++ b/native/landlock-run/packages/entry/README.md @@ -1,11 +1,11 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run English | [中文](README.zh.md) Landlock self-restrict-then-exec launcher for confining subprocesses on Linux: this entry package resolves the per-platform prebuilt binary, runs its functional enforcement probe, and builds its grant argv — consumers never spell launcher flags or parse launcher output themselves. ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { @@ -15,4 +15,4 @@ if (probe(launcher) !== 'unusable') { The launcher installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the whole process tree runs confined. Everything not granted is denied, and launcher failures exit `125` without running the command — fail-closed, never fail-open. The binary contract is pinned in the repo's `docs/cli-contract.md`; the C source rides this tarball (`src/main.c`) for audit. -Platform packages (`os`/`cpu`-selected optional dependencies, no JavaScript inside): `node-addon-landlock-run-linux-x64`, `node-addon-landlock-run-linux-arm64`. On hosts without one, `launcherPath()` returns a deterministic nonexistent path and `probe()` reports `'unusable'` — there is deliberately no install-time compile fallback. +Platform packages (`os`/`cpu`-selected optional dependencies, no JavaScript inside): `@deepseek-ai/node-addon-landlock-run-linux-x64`, `@deepseek-ai/node-addon-landlock-run-linux-arm64`. On hosts without one, `launcherPath()` returns a deterministic nonexistent path and `probe()` reports `'unusable'` — there is deliberately no install-time compile fallback. diff --git a/native/landlock-run/packages/entry/README.zh.md b/native/landlock-run/packages/entry/README.zh.md index e4fcd33a25..f462fbe3cb 100644 --- a/native/landlock-run/packages/entry/README.zh.md +++ b/native/landlock-run/packages/entry/README.zh.md @@ -1,11 +1,11 @@ -# node-addon-landlock-run +# @deepseek-ai/node-addon-landlock-run [English](README.md) | 中文 用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包定位对应平台的预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 ```js -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const launcher = launcherPath(); if (probe(launcher) !== 'unusable') { @@ -15,4 +15,4 @@ if (probe(launcher) !== 'unusable') { 启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:采用失败闭合策略,绝不在失败时放行。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。 -平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回一个固定但不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 +平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`@deepseek-ai/node-addon-landlock-run-linux-x64`、`@deepseek-ai/node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回一个固定但不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 diff --git a/native/landlock-run/packages/entry/package.json b/native/landlock-run/packages/entry/package.json index 56345b2847..1614df5ad2 100644 --- a/native/landlock-run/packages/entry/package.json +++ b/native/landlock-run/packages/entry/package.json @@ -1,5 +1,5 @@ { - "name": "node-addon-landlock-run", + "name": "@deepseek-ai/node-addon-landlock-run", "version": "0.0.1", "type": "module", "description": "Landlock self-restrict-then-exec launcher for sandboxing subprocesses on Linux: per-platform prebuilt static binaries plus the JS seam that resolves, probes, and speaks their CLI contract", @@ -35,7 +35,7 @@ "access": "public" }, "optionalDependencies": { - "node-addon-landlock-run-linux-arm64": "workspace:*", - "node-addon-landlock-run-linux-x64": "workspace:*" + "@deepseek-ai/node-addon-landlock-run-linux-arm64": "workspace:*", + "@deepseek-ai/node-addon-landlock-run-linux-x64": "workspace:*" } } diff --git a/native/landlock-run/packages/entry/src/index.ts b/native/landlock-run/packages/entry/src/index.ts index 7a4349a5ca..ec909f928a 100644 --- a/native/landlock-run/packages/entry/src/index.ts +++ b/native/landlock-run/packages/entry/src/index.ts @@ -53,7 +53,7 @@ export interface LauncherGrants { /** * Path of the launcher binary for this host: resolved from the per-platform - * npm package `node-addon-landlock-run--` (npm's + * npm package `@deepseek-ai/node-addon-landlock-run--` (npm's * `os`/`cpu` fields make installers fetch only the matching one). When the * package is not resolvable — a platform without one, or an install that * skipped the optional dependency — the returned fallback path points inside @@ -69,7 +69,7 @@ export interface LauncherGrants { export function launcherPath( resolvePackageJson: (specifier: string) => string = createRequire(import.meta.url).resolve, ): string { - const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}` + const platformPackage = `@deepseek-ai/node-addon-landlock-run-${process.platform}-${process.arch}` try { return join(dirname(resolvePackageJson(`${platformPackage}/package.json`)), 'bin', LAUNCHER_BIN) } catch { diff --git a/native/landlock-run/packages/entry/src/main.c b/native/landlock-run/packages/entry/src/main.c index af3c2eb3f0..e4e1f5c17e 100644 --- a/native/landlock-run/packages/entry/src/main.c +++ b/native/landlock-run/packages/entry/src/main.c @@ -31,7 +31,7 @@ * linked statically), so the whole audit surface is this file plus the * kernel's stable syscall contract. Built natively per architecture by * `scripts/build.ts` into the per-platform npm packages - * (`node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar, + * (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar, * exit codes, and report lines are pinned in `docs/cli-contract.md`. */ diff --git a/native/landlock-run/packages/linux-arm64/README.i18n.yaml b/native/landlock-run/packages/linux-arm64/README.i18n.yaml index f7e057193c..fc5c8f9b11 100644 --- a/native/landlock-run/packages/linux-arm64/README.i18n.yaml +++ b/native/landlock-run/packages/linux-arm64/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/landlock-run/packages/linux-arm64/README.md -README.md: e5117988cf0bae2227edaa041700c2f75753899c -README.zh.md: e502b0239b5ed862af579b21e36b8c47d7d6107e +README.md: dfcc9e97dc1393a42ff4b89ac009cdfd31e1497b +README.zh.md: 350044e92f1d0247222cc16c82f03588ed0154c9 diff --git a/native/landlock-run/packages/linux-arm64/README.md b/native/landlock-run/packages/linux-arm64/README.md index e5117988cf..dfcc9e97dc 100644 --- a/native/landlock-run/packages/linux-arm64/README.md +++ b/native/landlock-run/packages/linux-arm64/README.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-arm64 +# @deepseek-ai/node-addon-landlock-run-linux-arm64 English | [中文](README.zh.md) -Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. +Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. -Sibling: `node-addon-landlock-run-linux-x64`. +Sibling: `@deepseek-ai/node-addon-landlock-run-linux-x64`. diff --git a/native/landlock-run/packages/linux-arm64/README.zh.md b/native/landlock-run/packages/linux-arm64/README.zh.md index e502b0239b..350044e92f 100644 --- a/native/landlock-run/packages/linux-arm64/README.zh.md +++ b/native/landlock-run/packages/linux-arm64/README.zh.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-arm64 +# @deepseek-ai/node-addon-landlock-run-linux-arm64 [English](README.md) | 中文 -面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 -同级包:`node-addon-landlock-run-linux-x64`。 +同级包:`@deepseek-ai/node-addon-landlock-run-linux-x64`。 diff --git a/native/landlock-run/packages/linux-arm64/package.json b/native/landlock-run/packages/linux-arm64/package.json index af5467cead..14190e4765 100644 --- a/native/landlock-run/packages/linux-arm64/package.json +++ b/native/landlock-run/packages/linux-arm64/package.json @@ -1,7 +1,7 @@ { - "name": "node-addon-landlock-run-linux-arm64", + "name": "@deepseek-ai/node-addon-landlock-run-linux-arm64", "version": "0.0.1", - "description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by @deepseek-ai/node-addon-landlock-run, never imported", "repository": { "type": "git", "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", diff --git a/native/landlock-run/packages/linux-x64/README.i18n.yaml b/native/landlock-run/packages/linux-x64/README.i18n.yaml index 7050c110ef..cb0022b138 100644 --- a/native/landlock-run/packages/linux-x64/README.i18n.yaml +++ b/native/landlock-run/packages/linux-x64/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/landlock-run/packages/linux-x64/README.md -README.md: 68b5dfc9b6f437a387c3792ee047a1f11630aca0 -README.zh.md: 3b9578a7eb78dfc05977795ca521cf3a881e9f1a +README.md: d08cc0c4abbc74f64c5d1075dea796427211bd8f +README.zh.md: ed6839aa6230b16b82c67a716fc0a4128e5a977c diff --git a/native/landlock-run/packages/linux-x64/README.md b/native/landlock-run/packages/linux-x64/README.md index 68b5dfc9b6..d08cc0c4ab 100644 --- a/native/landlock-run/packages/linux-x64/README.md +++ b/native/landlock-run/packages/linux-x64/README.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-x64 +# @deepseek-ai/node-addon-landlock-run-linux-x64 English | [中文](README.zh.md) -Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. +Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. -Sibling: `node-addon-landlock-run-linux-arm64`. +Sibling: `@deepseek-ai/node-addon-landlock-run-linux-arm64`. diff --git a/native/landlock-run/packages/linux-x64/README.zh.md b/native/landlock-run/packages/linux-x64/README.zh.md index 3b9578a7eb..ed6839aa62 100644 --- a/native/landlock-run/packages/linux-x64/README.zh.md +++ b/native/landlock-run/packages/linux-x64/README.zh.md @@ -1,9 +1,9 @@ -# node-addon-landlock-run-linux-x64 +# @deepseek-ai/node-addon-landlock-run-linux-x64 [English](README.md) | 中文 -面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 -同级包:`node-addon-landlock-run-linux-arm64`。 +同级包:`@deepseek-ai/node-addon-landlock-run-linux-arm64`。 diff --git a/native/landlock-run/packages/linux-x64/package.json b/native/landlock-run/packages/linux-x64/package.json index 375d05332a..43c092d17b 100644 --- a/native/landlock-run/packages/linux-x64/package.json +++ b/native/landlock-run/packages/linux-x64/package.json @@ -1,7 +1,7 @@ { - "name": "node-addon-landlock-run-linux-x64", + "name": "@deepseek-ai/node-addon-landlock-run-linux-x64", "version": "0.0.1", - "description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by @deepseek-ai/node-addon-landlock-run, never imported", "repository": { "type": "git", "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", diff --git a/native/landlock-run/scripts/verify-packed-install.mjs b/native/landlock-run/scripts/verify-packed-install.mjs index 60f225a9d2..928fff50f7 100644 --- a/native/landlock-run/scripts/verify-packed-install.mjs +++ b/native/landlock-run/scripts/verify-packed-install.mjs @@ -31,7 +31,7 @@ import { entryDirs, packageDirs, platformDirs, readJson, root } from './repo.mjs const args = process.argv.slice(2); const currentPlatformOnly = args.includes('--current-platform-only'); const tarballDir = path.resolve(args.find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm')); -const entryPackageName = 'node-addon-landlock-run'; +const entryPackageName = '@deepseek-ai/node-addon-landlock-run'; function tarballName(manifest) { if (manifest.name.startsWith('@')) { @@ -180,10 +180,10 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1'; -const platformPackage = 'node-addon-landlock-run-' + process.platform + '-' + process.arch; +const platformPackage = '@deepseek-ai/node-addon-landlock-run-' + process.platform + '-' + process.arch; const resolved = launcherPath(); assert.ok(path.isAbsolute(resolved), 'launcherPath must be absolute'); assert.ok(resolved.includes(path.join(...platformPackage.split('/'))), 'launcherPath must point into the platform package: ' + resolved); diff --git a/native/landlock-run/test/entry.test.js b/native/landlock-run/test/entry.test.js index 2e2cfe8f17..2b535559a2 100644 --- a/native/landlock-run/test/entry.test.js +++ b/native/landlock-run/test/entry.test.js @@ -15,7 +15,7 @@ import { grantArgs, launcherPath, probe, -} from 'node-addon-landlock-run'; +} from '@deepseek-ai/node-addon-landlock-run'; // --- constants are part of the CLI contract --- assert.equal(LAUNCHER_BIN, 'landlock-run'); @@ -31,7 +31,7 @@ assert.deepEqual( assert.deepEqual(grantArgs({ readWrite: ['/a'], readOnly: ['/b'] }), ['--ro', '/b', '--rw', '/a']); // --- launcherPath: resolves the platform package next to its package.json --- -const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}`; +const platformPackage = `@deepseek-ai/node-addon-landlock-run-${process.platform}-${process.arch}`; const resolvedViaSeam = launcherPath((specifier) => { assert.equal(specifier, `${platformPackage}/package.json`); return path.join('/fake-install', specifier); diff --git a/native/landlock-run/test/launcher.test.js b/native/landlock-run/test/launcher.test.js index 55385d2156..a78501cd4a 100644 --- a/native/landlock-run/test/launcher.test.js +++ b/native/landlock-run/test/launcher.test.js @@ -22,7 +22,7 @@ import { grantArgs, launcherPath, probe, -} from 'node-addon-landlock-run'; +} from '@deepseek-ai/node-addon-landlock-run'; const FATAL_PREFIX = 'landlock-run: '; const PARTIAL_NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'; diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 6a29c3f6c8..a771acb1d3 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "cordis": "^4.0.0-rc.7", - "node-addon-landlock-run": "workspace:*" + "@deepseek-ai/node-addon-landlock-run": "workspace:*" } } diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 7579292fe1..7255ee43c9 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -5,7 +5,7 @@ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { launcherPath } from 'node-addon-landlock-run' +import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts index 23546e5d92..b578716c43 100644 --- a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run' +import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index b9f817d6e5..e6c6ce351d 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -84,7 +84,7 @@ "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "node-addon-landlock-run": "workspace:*", + "@deepseek-ai/node-addon-landlock-run": "workspace:*", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index 46f3f56dc3..53722aa7e5 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -15,7 +15,7 @@ import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import { SessionId } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import type { ToolResult } from '@deepseek-ai/dsh-tools' -import { launcherPath } from 'node-addon-landlock-run' +import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import * as agentSpine from '../src/index.ts' const bwrapUsable = spawnSync('bwrap', [ diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 43fb941975..cbc1e9ad55 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md -README.md: f6a1cc2b3e454e0670a564151d41182ec515bdcf -README.zh.md: 18b66af350932fc8d5c4f184d0e7fa049f910250 +README.md: 23d3a32451c105c71c0a7399ed051288b70753f3 +README.zh.md: 165a6fc88a9fdd219c3ddb016cdf415504556d8c diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index f6a1cc2b3e..23d3a32451 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -12,7 +12,7 @@ Policy is per call; the provider stores only the mechanism and cached runner ver The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. -[`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. +[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. ```yaml - id: sandbox diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 18b66af350..165a6fc88a 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -12,7 +12,7 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 -[`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止契约漂移。 +[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止契约漂移。 ```yaml - id: sandbox diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index f7004c4d5c..eace2ef08c 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -31,7 +31,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { - "node-addon-landlock-run": "workspace:*", + "@deepseek-ai/node-addon-landlock-run": "workspace:*", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 64e92d9bf2..7e9405d14b 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -12,7 +12,7 @@ import { LAUNCHER_FAILURE_EXIT, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock, -} from 'node-addon-landlock-run' +} from '@deepseek-ai/node-addon-landlock-run' import { Context } from 'cordis' import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts index cee0f00852..5b76390319 100644 --- a/packages/sandbox/sandbox-local/src/profiles.ts +++ b/packages/sandbox/sandbox-local/src/profiles.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-sandbox-local/profiles */ -import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run' +import { grantArgs as landlockGrantArgs } from '@deepseek-ai/node-addon-landlock-run' import { writableRoots } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' diff --git a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts index 6e2faecc6b..ff4947a4ca 100644 --- a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -import { launcherPath } from 'node-addon-landlock-run' +import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 74d4c2a8a1..b1b8b2c4a8 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run' +import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 612751e6da..5f4fa5a3bb 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -23,6 +23,7 @@ const packageDir = fileURLToPath(new URL('..', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)) const nativeDir = join(repoRoot, 'native/landlock-run') const sourceLauncher = join(nativeDir, 'packages', `linux-${process.arch}`, 'bin', 'landlock-run') +const platformPackageName = `@deepseek-ai/node-addon-landlock-run-linux-${process.arch}` /** The harness closure the consumer needs; native tarballs are packed through their mode-preserving release script. */ const WORKSPACE_CLOSURE = [ @@ -108,7 +109,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' import { Context } from 'cordis' - import { launcherPath } from 'node-addon-landlock-run' + import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' const ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) @@ -145,7 +146,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- }) it('installs this checkout\'s launcher for the host: present, executable, byte-identical, and right ELF arch', () => { - const installed = join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run') + const installed = join(consumerDir, 'node_modules', ...platformPackageName.split('/'), 'bin', 'landlock-run') expect(existsSync(installed), 'platform package missing from the installed tree').toBe(true) // A tarball or extraction step that strips the mode bit would leave the // probe failing exactly like a non-enforcing kernel — assert it apart. @@ -156,7 +157,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- it('the installed provider resolves the launcher INSIDE the consumer node_modules platform package', () => { expect(verdict.launcher) - .toBe(join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run')) + .toBe(join(consumerDir, 'node_modules', ...platformPackageName.split('/'), 'bin', 'landlock-run')) }) it('confines through the installed launcher (enforcing kernel) or fails closed (non-enforcing) — never unconfined', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef10b7c2d7..a93b757791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -853,12 +853,12 @@ importers: native/landlock-run: devDependencies: + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:* + version: link:packages/entry '@types/node': specifier: ^26.0.1 version: 26.1.2 - node-addon-landlock-run: - specifier: workspace:* - version: link:packages/entry tsx: specifier: ^4.20.6 version: 4.22.4 @@ -868,10 +868,10 @@ importers: native/landlock-run/packages/entry: optionalDependencies: - node-addon-landlock-run-linux-arm64: + '@deepseek-ai/node-addon-landlock-run-linux-arm64': specifier: workspace:* version: link:../linux-arm64 - node-addon-landlock-run-linux-x64: + '@deepseek-ai/node-addon-landlock-run-linux-x64': specifier: workspace:* version: link:../linux-x64 @@ -1010,12 +1010,12 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - node-addon-landlock-run: - specifier: workspace:* - version: link:../../../native/landlock-run/packages/entry packages/bash/pwsh-local: dependencies: @@ -2969,12 +2969,12 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../context/workspace-context + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - node-addon-landlock-run: - specifier: workspace:* - version: link:../../../native/landlock-run/packages/entry packages/examples/cli-demo: devDependencies: @@ -4255,7 +4255,7 @@ importers: packages/sandbox/sandbox-local: dependencies: - node-addon-landlock-run: + '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry schemastery: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0a446d77f8..dabd0f7805 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -31,10 +31,14 @@ const vendoredPackages = new Set([ '@cordisjs/plugin-logger-console', ]) const publicLandlockPackages = new Set([ - 'node-addon-landlock-run', - 'node-addon-landlock-run-linux-arm64', - 'node-addon-landlock-run-linux-x64', + '@deepseek-ai/node-addon-landlock-run', + '@deepseek-ai/node-addon-landlock-run-linux-arm64', + '@deepseek-ai/node-addon-landlock-run-linux-x64', ]) +/** Deliberate source payloads whose exact bytes are part of the package's audit surface. */ +const publicationSourceAllowlist: Readonly> = { + '@deepseek-ai/node-addon-landlock-run': ['src/main.c'], +} const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness.git' const localArtifactDirs = new Set(['node_modules']) @@ -200,8 +204,9 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } if (manifest.name?.startsWith('@deepseek-ai/')) { + const allowedSources = publicationSourceAllowlist[manifest.name] ?? [] for (const file of manifest.files ?? []) { - if (isForbiddenPublicationFile(file)) { + if (isForbiddenPublicationFile(file) && !allowedSources.includes(file)) { errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`) } } diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index e56cce670a..04ea0cf8eb 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -41,9 +41,9 @@ const DEV_ONLY_AREAS = [ /** First-party public native packages: reachable at runtime but not third-party. */ const FIRST_PARTY = new Set([ - 'node-addon-landlock-run', - 'node-addon-landlock-run-linux-arm64', - 'node-addon-landlock-run-linux-x64', + '@deepseek-ai/node-addon-landlock-run', + '@deepseek-ai/node-addon-landlock-run-linux-arm64', + '@deepseek-ai/node-addon-landlock-run-linux-x64', ]) /** @@ -587,7 +587,7 @@ ${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.lice ## First-party native packages -\`node-addon-landlock-run\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +\`@deepseek-ai/node-addon-landlock-run\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. ` } diff --git a/tsconfig.base.json b/tsconfig.base.json index 634464cb9b..2965a1f794 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -38,7 +38,7 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], - "node-addon-landlock-run": ["./native/landlock-run/packages/entry/src/index.ts"], + "@deepseek-ai/node-addon-landlock-run": ["./native/landlock-run/packages/entry/src/index.ts"], "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], From 928c99876e8e3a66a730f759236525f7944a4a0f Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 5 Aug 2026 21:50:44 +0800 Subject: [PATCH 010/100] feat: add optional dsh badge skill provider --- ...26-08-06-bundled-dsh-badge-skill.i18n.yaml | 6 + .../2026-08-06-bundled-dsh-badge-skill.md | 25 +++ .../2026-08-06-bundled-dsh-badge-skill.zh.md | 25 +++ apps/cli/composition.md | 3 + apps/cli/config/base.cordis.yml | 4 + apps/cli/package.json | 1 + apps/cli/tests/dsh-badge.snapshot.ts | 173 ++++++++++++++++++ apps/cli/tests/fixtures/dsh-badge/cordis.yml | 9 + .../fixtures/dsh-badge/default.cordis.yml | 6 + apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 53 ++++++ docs/capability-seams.md | 4 +- docs/config-catalog.md | 1 + docs/module-graph.md | 4 + knip.json | 3 +- packages/skill/README.i18n.yaml | 4 +- packages/skill/README.md | 1 + packages/skill/README.zh.md | 1 + packages/skill/skill-badge/README.i18n.yaml | 6 + packages/skill/skill-badge/README.md | 22 +++ packages/skill/skill-badge/README.zh.md | 22 +++ .../skill/skill-badge/assets/dsh-badge.md | 31 ++++ .../skill/skill-badge/assets/dsh-badge.png | Bin 0 -> 12339 bytes packages/skill/skill-badge/package.json | 37 ++++ packages/skill/skill-badge/src/index.ts | 60 ++++++ packages/skill/skill-badge/src/invariant.ts | 30 +++ .../skill-badge/tests/skill-badge.spec.ts | 40 ++++ packages/skill/skill-badge/tsconfig.json | 14 ++ pnpm-lock.yaml | 15 ++ scripts/check-workspace-constraints.ts | 1 + scripts/gen-doc-graphs.ts | 2 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + vitest.snapshot.config.ts | 1 + 33 files changed, 601 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md create mode 100644 apps/cli/tests/dsh-badge.snapshot.ts create mode 100644 apps/cli/tests/fixtures/dsh-badge/cordis.yml create mode 100644 apps/cli/tests/fixtures/dsh-badge/default.cordis.yml create mode 100644 apps/cli/tests/fixtures/dsh-badge/snapshot.ts create mode 100644 packages/skill/skill-badge/README.i18n.yaml create mode 100644 packages/skill/skill-badge/README.md create mode 100644 packages/skill/skill-badge/README.zh.md create mode 100644 packages/skill/skill-badge/assets/dsh-badge.md create mode 100644 packages/skill/skill-badge/assets/dsh-badge.png create mode 100644 packages/skill/skill-badge/package.json create mode 100644 packages/skill/skill-badge/src/index.ts create mode 100644 packages/skill/skill-badge/src/invariant.ts create mode 100644 packages/skill/skill-badge/tests/skill-badge.spec.ts create mode 100644 packages/skill/skill-badge/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml new file mode 100644 index 0000000000..bdc103ef46 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md +2026-08-06-bundled-dsh-badge-skill.md: afe0b21d64a414a9e78ef55459a42c0d3817e3fd +2026-08-06-bundled-dsh-badge-skill.zh.md: de1ec989570b07987b12f0a291c84643aa5531fd diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md new file mode 100644 index 0000000000..afe0b21d64 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md @@ -0,0 +1,25 @@ +# Agent Note: Bundled dsh badge skill + +Status: implemented + +English | [中文](2026-08-06-bundled-dsh-badge-skill.zh.md) + +## Problem + +DeepSeek Harness has an official attribution badge skill, but keeping it only in a developer's personal skill directory makes it unavailable to other DSH installations and gives the shipped application no explicit opt-in point. + +## Decision + +`@deepseek-ai/dsh-skill-badge` is a native Cordis plugin that registers one immutable bundled provider on `ctx.skills`. The provider owns the `dsh-badge` summary, instruction body, and PNG resource base; `dsh-tool-skill` remains the sole owner of model-facing catalog and loader rendering. + +The shipped CLI composition declares `skill-badge` as disabled. Enabling that existing row is the explicit opt-in; disabled installations advertise no badge skill and gain no model-visible content. + +The provider uses the bundled rank after project, custom, and user filesystem sources, so a user-owned `dsh-badge` definition can override it through the ordinary registry precedence contract. Provider disposal removes the contribution through the registry-owned effect. + +## Alternatives considered + +A Codex marketplace plugin was rejected because it would install into a different runtime and would not participate in DSH's `ctx.skills` seam. Mounting `dsh-skill-local` over the packaged files was rejected because filesystem discovery, parsing, and watching add lifecycle machinery that an immutable single-skill provider does not need. + +## Consequences + +The badge instructions and source PNG are versioned with DSH and resolve through a packaged directory resource base. The provider has no configuration surface. Package tests pin provider lifecycle and the official PNG bytes, while a keyless assembled-application snapshot pins the enabled catalog and loaded skill body. diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md new file mode 100644 index 0000000000..de1ec98957 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 内置 dsh 徽章 skill + +Status: implemented + +[English](2026-08-06-bundled-dsh-badge-skill.md) | 中文 + +## 问题 + +DeepSeek Harness 已有官方署名徽章 skill(技能),但如果它只保存在某位开发者的个人 skill 目录中,其他 DSH 安装实例便无法使用,交付的应用也没有显式的选择加入点。 + +## 决策 + +`@deepseek-ai/dsh-skill-badge` 是一个原生 Cordis 插件,会在 `ctx.skills` 上注册一个不可变的内置提供方。该提供方负责 `dsh-badge` 的摘要、指令正文和 PNG 资源基底;`dsh-tool-skill` 仍是面向模型的目录与 loader 渲染的唯一归属方。 + +交付的 CLI(命令行界面)组合将 `skill-badge` 声明为禁用。启用这个现有配置行就是显式选择加入;禁用它的安装实例不会公开任何徽章 skill,也不会获得任何模型可见内容。 + +该提供方使用排在项目、自定义及用户文件系统来源之后的内置 rank,因此用户自有的 `dsh-badge` 定义可通过注册表的常规优先级契约覆盖它。提供方释放时,注册表拥有的 effect 会移除该贡献。 + +## 曾考虑的替代方案 + +未采用 Codex marketplace 插件,因为它会安装到不同的运行时,无法参与 DSH 的 `ctx.skills` seam。未采用使用 `dsh-skill-local` 挂载随包文件的方案,因为文件系统发现、解析和监视会引入不必要的生命周期机制,而不可变的单一 skill 提供方并不需要这些机制。 + +## 后果 + +徽章指令和源 PNG 随 DSH 一同纳入版本管理,并通过以随包目录为基础的资源基底解析。该提供方没有配置面。包测试固定提供方生命周期和官方 PNG 的字节内容;无密钥的组装应用快照则固定启用后的目录和已加载的 skill 正文。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 28f58bcf4d..8edf39a07a 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -72,6 +72,8 @@ flowchart LR cfg --> plugin_dsh_base_skill plugin_dsh_base_skill_local["skill-local
@deepseek-ai/dsh-skill-local"] cfg --> plugin_dsh_base_skill_local + plugin_dsh_base_skill_badge["skill-badge
@deepseek-ai/dsh-skill-badge"] + cfg --> plugin_dsh_base_skill_badge plugin_dsh_base_tool_skill["tool-skill
@deepseek-ai/dsh-tool-skill"] cfg --> plugin_dsh_base_tool_skill plugin_dsh_base_commands["commands
@deepseek-ai/dsh-commands"] @@ -182,6 +184,7 @@ flowchart LR | `workspace-context` | `@deepseek-ai/dsh-workspace-context` | | `skill` | `@deepseek-ai/dsh-skill` | | `skill-local` | `@deepseek-ai/dsh-skill-local` | +| `skill-badge` | `@deepseek-ai/dsh-skill-badge` | | `tool-skill` | `@deepseek-ai/dsh-tool-skill` | | `commands` | `@deepseek-ai/dsh-commands` | | `goal` | `@deepseek-ai/dsh-goal` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index dddf2fc1b5..f5f39a6ae5 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -204,6 +204,10 @@ - id: skill-local name: '@deepseek-ai/dsh-skill-local' +- id: skill-badge + name: '@deepseek-ai/dsh-skill-badge' + disabled: true + - id: tool-skill name: '@deepseek-ai/dsh-tool-skill' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4ba1da7e86..dfe35e6159 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -92,6 +92,7 @@ "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-settings-local": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-badge": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", diff --git a/apps/cli/tests/dsh-badge.snapshot.ts b/apps/cli/tests/dsh-badge.snapshot.ts new file mode 100644 index 0000000000..d78f4c743d --- /dev/null +++ b/apps/cli/tests/dsh-badge.snapshot.ts @@ -0,0 +1,173 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +const binScript = fileURLToPath(new URL('./fixtures/dsh-badge/snapshot.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('./fixtures/dsh-badge/cordis.yml', import.meta.url)) +const defaultConfigPath = fileURLToPath(new URL('./fixtures/dsh-badge/default.cordis.yml', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const badgeAssetsPath = fileURLToPath(new URL('../../../packages/skill/skill-badge/assets/', import.meta.url)) + +describe('dsh badge assembled snapshot', () => { + it('advertises and loads the opt-in bundled skill through the shipped app', async () => { + const disabled = await runLoaderSmoke({ + label: 'disabled dsh badge skill snapshot', + tempDirPrefix: 'headless-snapshot-dsh-badge-disabled-', + binScript, + libBinScript: binScript, + configPath: defaultConfigPath, + tsconfigPath, + }) + const enabled = await runLoaderSmoke({ + label: 'dsh badge skill snapshot', + tempDirPrefix: 'headless-snapshot-dsh-badge-', + binScript, + libBinScript: binScript, + configPath, + tsconfigPath, + }) + const disabledSnapshot = JSON.parse(disabled.stdout) as unknown + const enabledSnapshot = JSON.parse( + enabled.stdout.replaceAll(badgeAssetsPath, '{{badgeAssetsPath}}'), + ) as unknown + + expect(disabled.stderr).toBe('') + expect(enabled.stderr).toBe('') + expect(disabledSnapshot).toMatchInlineSnapshot(` + { + "result": { + "content": [ + { + "text": "Error: skill "dsh-badge" is unknown or no longer available", + "type": "text", + }, + ], + "error": { + "message": "skill "dsh-badge" is unknown or no longer available", + }, + "isError": true, + }, + } + `) + expect(enabledSnapshot).toMatchInlineSnapshot(` + { + "catalog": [ + { + "text": " + A skill is a reusable set of task-specific instructions. The following skills are available in this session: + + + - \`dsh-badge\`: Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet. + + + If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. + ", + "type": "text", + }, + ], + "result": { + "content": [ + { + "text": " + + Base directory for this skill: {{badgeAssetsPath}} + Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. + + + + # dsh Badge + + Add the official “powered by dsh” badge without recreating or restyling it. + + ## Assets + + - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 + - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` + - Project URL: \`https://github.com/deepseek-harness/deepseek-harness\` + + ## Markdown + + Use this linked badge in Markdown: + + \`\`\`markdown + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) + \`\`\` + + If attribution should not be linked, use: + + \`\`\`markdown + ![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white) + \`\`\` + + ## Usage rules + + - For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image. + - For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge. + - Preserve the badge's 121×20 dimensions and aspect ratio. + - Place the badge at the end of the attributed document or section unless the user specifies another position. + - Do not substitute another color, logo, label, or project URL. + + + ", + "type": "text", + }, + ], + "isError": false, + "value": { + "content": "# dsh Badge + + Add the official “powered by dsh” badge without recreating or restyling it. + + ## Assets + + - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 + - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` + - Project URL: \`https://github.com/deepseek-harness/deepseek-harness\` + + ## Markdown + + Use this linked badge in Markdown: + + \`\`\`markdown + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) + \`\`\` + + If attribution should not be linked, use: + + \`\`\`markdown + ![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white) + \`\`\` + + ## Usage rules + + - For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image. + - For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge. + - Preserve the badge's 121×20 dimensions and aspect ratio. + - Place the badge at the end of the attributed document or section unless the user specifies another position. + - Do not substitute another color, logo, label, or project URL. + ", + "name": "dsh-badge", + "provider": "dsh-badge", + "resourceBase": { + "kind": "directory", + "path": "{{badgeAssetsPath}}", + }, + }, + }, + "summary": { + "description": "Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.", + "invocation": { + "modelInvocable": true, + "userInvocable": true, + }, + "name": "dsh-badge", + "provider": "dsh-badge", + "resourceBase": { + "kind": "directory", + "path": "{{badgeAssetsPath}}", + }, + "source": "bundled", + }, + } + `) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/apps/cli/tests/fixtures/dsh-badge/cordis.yml b/apps/cli/tests/fixtures/dsh-badge/cordis.yml new file mode 100644 index 0000000000..b3bfdb1b04 --- /dev/null +++ b/apps/cli/tests/fixtures/dsh-badge/cordis.yml @@ -0,0 +1,9 @@ +- id: skill-badge + disabled: false + +- id: skill-local + config: + watch: false + +- id: telemetry-otel + disabled: true diff --git a/apps/cli/tests/fixtures/dsh-badge/default.cordis.yml b/apps/cli/tests/fixtures/dsh-badge/default.cordis.yml new file mode 100644 index 0000000000..ac3e48441a --- /dev/null +++ b/apps/cli/tests/fixtures/dsh-badge/default.cordis.yml @@ -0,0 +1,6 @@ +- id: skill-local + config: + watch: false + +- id: telemetry-otel + disabled: true diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts new file mode 100644 index 0000000000..99379b4fe4 --- /dev/null +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -0,0 +1,53 @@ +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-skill' +import type {} from '@deepseek-ai/dsh-tools' + +const overlayPath = process.argv[2] +if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path') +const baseConfigPath = fileURLToPath(new URL('../../../config/base.cordis.yml', import.meta.url)) +const ctx = await boot('dsh-badge-snapshot', baseConfigPath, loadOverlayPatches('dsh-badge-snapshot', overlayPath)) + +try { + const agentId = SessionId('dsh-badge-snapshot') + const session = ctx.sessions.create(agentId, { meta: { cwd: process.cwd() } }) + const agent: Agent = { + ctx: new Context(), + id: agentId, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => { throw new Error('dsh-badge snapshot must receive the catalog at the step boundary') }, + cancel: () => {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + [], + { turn: 1, step: 1, signal: new AbortController().signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + ) + const catalog = decision.kind === 'enter' + ? decision.messages.find(message => message.role === 'user' + && message.source.kind === 'skill-catalog')?.content + : undefined + const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge') + const result = await ctx.tools.execute({ + callId: CallId('dsh-badge-snapshot'), + name: 'skill', + arguments: { name: 'dsh-badge' }, + signal: new AbortController().signal, + }) + process.stdout.write(`${JSON.stringify({ catalog, summary, result })}\n`) +} finally { + await ctx.fiber.dispose() +} diff --git a/docs/capability-seams.md b/docs/capability-seams.md index cb24cee7d5..a5f20b5349 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -89,6 +89,7 @@ flowchart LR svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] + pkg_skill_badge["skill-badge"] pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent service"] pkg_acp["acp"] @@ -219,6 +220,7 @@ flowchart LR pkg_settings --> svc_settings pkg_settings_local --> svc_settings pkg_skill --> svc_skills + pkg_skill_badge --> svc_skills pkg_skill_local --> svc_skills pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore @@ -373,7 +375,7 @@ flowchart LR | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | -| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | +| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ffa3d5bdd4..449addad8a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2362,6 +2362,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) +- `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 0e2e7e0c37..7f3f68603f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -56,6 +56,7 @@ flowchart TD end subgraph group_skill["packages/skill"] pkg_skill["skill"] + pkg_skill_badge["skill-badge"] pkg_skill_local["skill-local"] pkg_tool_skill["tool-skill"] end @@ -303,6 +304,8 @@ flowchart TD pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_llm --> pkg_timeout + pkg_skill_badge --> pkg_invariants + pkg_skill_badge --> pkg_skill pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -1106,6 +1109,7 @@ flowchart TD | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/knip.json b/knip.json index dfb8058d7c..8d10010644 100644 --- a/knip.json +++ b/knip.json @@ -622,7 +622,8 @@ "apps/cli": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/**/*.snapshot.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml index 2d424c61dd..74875f2aa3 100644 --- a/packages/skill/README.i18n.yaml +++ b/packages/skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/README.md -README.md: d10049ac3e741350fddb42f430b70f063da1d12d -README.zh.md: 67f2da6f75edecd180ff861122c6392684ed9bdb +README.md: 533904859ad998de4f371a073fde98b68660097b +README.zh.md: 1fad581cc61a05251f671577dcb7edab37281283 diff --git a/packages/skill/README.md b/packages/skill/README.md index d10049ac3e..533904859a 100644 --- a/packages/skill/README.md +++ b/packages/skill/README.md @@ -7,6 +7,7 @@ This family discovers reusable agent instructions and exposes them to the model | Package | Role | ctx key | |---|---|---| | [`skill/`](skill/README.md) | Defines skill provider registration and lookup | `ctx.skills` | +| [`skill-badge/`](skill-badge/README.md) | Contributes the optional bundled dsh badge skill | registers on `ctx.skills` | | [`skill-local/`](skill-local/README.md) | Discovers skills from local filesystems | registers on `ctx.skills` | | [`tool-skill/`](tool-skill/README.md) | Publishes the skill catalog and model-facing loader | registers on `ctx.tools` | diff --git a/packages/skill/README.zh.md b/packages/skill/README.zh.md index 67f2da6f75..1fad581cc6 100644 --- a/packages/skill/README.zh.md +++ b/packages/skill/README.zh.md @@ -7,6 +7,7 @@ | 包 | 职责 | ctx 键 | |---|---|---| | [`skill/`](skill/README.md) | 定义 skill 提供方注册和查找 | `ctx.skills` | +| [`skill-badge/`](skill-badge/README.md) | 贡献可选的内置 dsh 徽章 skill | 注册到 `ctx.skills` | | [`skill-local/`](skill-local/README.md) | 从本地文件系统发现 skill | 注册到 `ctx.skills` | | [`tool-skill/`](tool-skill/README.md) | 发布 skill 目录和面向模型的 loader | 注册到 `ctx.tools` | diff --git a/packages/skill/skill-badge/README.i18n.yaml b/packages/skill/skill-badge/README.i18n.yaml new file mode 100644 index 0000000000..4dda53481c --- /dev/null +++ b/packages/skill/skill-badge/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/skill/skill-badge/README.md +README.md: 49b38023a7c110bb52bb351668905c702e251216 +README.zh.md: bf7eb0d7d4c0552c07f9cdf5665f8a20df829483 diff --git a/packages/skill/skill-badge/README.md b/packages/skill/skill-badge/README.md new file mode 100644 index 0000000000..49b38023a7 --- /dev/null +++ b/packages/skill/skill-badge/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-skill-badge + +English | [中文](README.zh.md) + +Optional bundled skill provider that contributes `dsh-badge` to `ctx.skills`. The skill supplies the official “powered by dsh” Markdown snippets and the packaged PNG for systems that cannot import a remote image reliably. + +Mount the plugin to enable the provider. It has no configuration. The shipped CLI composition includes the plugin as `disabled: true`; users must explicitly enable its `skill-badge` row before the skill enters a catalog. + +The provider exposes its packaged `assets/` directory as the skill resource base. `dsh-badge.png` is the 726×120 source asset, and consumers render it at 121×20. + +## Model Experience + +Indirectly, through `@deepseek-ai/dsh-tool-skill`, which renders the catalog entry and selected skill body. + +#### KV Cache effect + +Disabled by default, the plugin changes no request. When enabled, its catalog entry and any loaded body change the provider KV prefix at their insertion points. + +## Known Limitations and Deferred Work + +- The provider contributes one fixed skill and has no runtime customization. +- Remote Markdown uses Shields.io; use the packaged PNG when the target cannot fetch remote images reliably. diff --git a/packages/skill/skill-badge/README.zh.md b/packages/skill/skill-badge/README.zh.md new file mode 100644 index 0000000000..bf7eb0d7d4 --- /dev/null +++ b/packages/skill/skill-badge/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-skill-badge + +[English](README.md) | 中文 + +可选的内置 skill(技能)提供方,向 `ctx.skills` 贡献 `dsh-badge`。该 skill 提供官方「powered by dsh」Markdown 片段和随包分发的 PNG,供无法可靠导入远程图片的系统使用。 + +挂载该插件即可启用提供方。它没有配置。交付的 CLI(命令行界面)组合以 `disabled: true` 包含该插件;用户必须显式启用其 `skill-badge` 配置行,该 skill 才会进入目录。 + +该提供方将随包分发的 `assets/` 目录作为 skill 资源基底公开。`dsh-badge.png` 是尺寸为 726×120 的源图资源,消费方以 121×20 的尺寸渲染。 + +## 模型体验 + +通过 `@deepseek-ai/dsh-tool-skill` 间接影响模型;该包会渲染目录条目和所选 skill 的正文。 + +#### KV Cache 影响 + +该插件默认禁用,不会改变任何请求。启用后,其目录条目和任何已加载正文都会在各自插入点改变提供方的 KV 前缀。 + +## 已知限制与暂缓事项 + +- 该提供方只贡献一个固定 skill,不提供运行时自定义。 +- 远程 Markdown 使用 Shields.io;当目标环境无法可靠获取远程图片时,请使用随包分发的 PNG。 diff --git a/packages/skill/skill-badge/assets/dsh-badge.md b/packages/skill/skill-badge/assets/dsh-badge.md new file mode 100644 index 0000000000..9905de1ed9 --- /dev/null +++ b/packages/skill/skill-badge/assets/dsh-badge.md @@ -0,0 +1,31 @@ +# dsh Badge + +Add the official “powered by dsh” badge without recreating or restyling it. + +## Assets + +- Local PNG: [`dsh-badge.png`](dsh-badge.png), 726×120 source image; render at 121×20 +- Shields.io image URL: `https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white` +- Project URL: `https://github.com/deepseek-harness/deepseek-harness` + +## Markdown + +Use this linked badge in Markdown: + +```markdown +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +``` + +If attribution should not be linked, use: + +```markdown +![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white) +``` + +## Usage rules + +- For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image. +- For Feishu and other systems that import remote images unreliably, upload `dsh-badge.png` from this skill directory instead of generating another badge. +- Preserve the badge's 121×20 dimensions and aspect ratio. +- Place the badge at the end of the attributed document or section unless the user specifies another position. +- Do not substitute another color, logo, label, or project URL. diff --git a/packages/skill/skill-badge/assets/dsh-badge.png b/packages/skill/skill-badge/assets/dsh-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..bf91ecca9790971072e946b56c01bbf99e26abd4 GIT binary patch literal 12339 zcmdUVV|N`5+jg8C+qN1vb{Z#btj4zO2D`ECrg788wrw`HZ71*ay4U+7o-bLmX0Hs+ zIdvdh>5DW9A|WCK1O$q#jD!jV1k^lm>L&!>qsd;9dWW#%6&n*qC z=fQ(QFvZWsL_wb!0+qz+rzMhNKu}Oe;IeN)l+Kb?ny`>KNPR&cVI_7bL3r5Zj$#?) z?6sogH<;w#+t)=}7GA%*m|IxXkJ)<$D3azla_E@vjq<%WWZmx^EY&fwBloZ={Akp- zWoJiL<~GLvZiN3Gf%k*he+N|%DdfQK$URyR^63A5G5YY02K4U)8*V3N$iEZ#NQMaA z|4u}~R>J&uq8~~t=-&xs6UhHNmyw}mXJ?mB`B19Y!u>BK$XMvkO1PAo<+?bGYV;bv zwKluKmp0gtsdAD;jIV>_Ta2!_F+-S_qB_IUn}PL;9b zreZV^kN@qQ=B+35nE^0EtKnh z5~N{fKK(`V5@cd*Z0x#bJrF~pTKt9UD(@nQH{cw2AYBS_vsqPd3p*?cU+>|zjjoYHpz`;|smBwX92+QX~e5z~a9l98wc96JMK z0V~aJK(1jqX7D%&8+JmtjVOw|n)-gcNMO-xc0HPvUA-FPS^uElnwA!4+>c)<`sVW$ zuFYP+CGdmkiW1FJPI^TDc;)HmpGpJ92-84#fJ?SYgk$cgs;H>Ey<96&N$-uOtJ77Y0VgUyhsIlf;uMV;Njb`mK%EiWU<&H2-}**DBGV&c{Gc z=0}1DY7Pz#_p_D5yFr)z@umcF2ukQYGZ4l=z~xxxN2{N?2$>lfa{n^1=K#|Zfy4GM zo5JQ!06B?NEU6te77C^W3;oX`;7foJafXwb7A`h>m^8~3&Sc~eUx_LH-R+VSGMU_w z4n`_~*J`PjA&3FUhB@uE>VWq*I26Kz4#kNaq2q;$TpkB~j?QwumazY-R}ZQ|TA>BF zZmrcPNO=E92Gyb@R)e)UNRjs!&L-$fyx^zpgQ@)DFWHp;dnJT8m{PS;l@9;cK}vL~ zK&sw37T3eW!{r7CImum2@NJfBgiX z0v(f@fc2Z7Z+IXi02vDuoa!W-^(_5u5!;s( z7CrBa4Y8{r$w=JS%K@_9|5_2`3yauNoekXyp&4_N^KKBUga!q7rrmUbY$z%TW$eRI zSw#x7j?{nA#qT+fbk?b}mK2jZTWNZ~j15po=h$5^2vBb@0OD}FU=W(wt3Dh?%VWQi z#$_{KZ?{ZuIhZ5l1AJaG?}NOpzDT@km*=}vd3Q`KtoQ5O_lJ6an|j;DpZWR6b7h*q z5fm&UZ$0l9Ma2%FNOnh36|;B)kCEReS6e&|+o=_E1i7qd9Z$NoCQ)^R8-+D1^!sS| zop%O-7j^P;y+Z_%I#!ecA#Si=oi3CY7^e{wB#!-Nf^?inr}%ca>;T;R`f?jjAXx9E%Q>RIt%(Bt>csMn92& z(>zPqF9Qlnm-l*q0*~2N*PYOxSu> zCL|`Jw13(k&u(!$-g`N}+#Zky3hkmU&dFtOv>J`IoQ+`Q6$ZJu`@n`9{u?Cij#2I-Moe@7;OGC8}R5W!no_^qAKDyE{U zYE)`C&L&Lqgy_ZLf=Nb(#zz^6Mu1<9U%=MS{IwPYN0Sb3kni_=7b|p^*5%Cbpz%ki zAto|Xr``7vi2g>h(=(<*e;T=bGGn2=1kBh2S17is0gKi&ja(9&RvoPKXI?IP{Ou=b zgBBoD(r!^m(f$NC;d(d%W2=ZGpIsJ7PaNIufmX=pE+iIF8nFV59HbE9pkNTRu_h+7 zd3<;y#C4Ihu}v;S0TO;XpbGZU7!hT=JzhXVg{FenteXKw^tVN5^YDlW!jG15Z)>Qi zT9`Ht+TVABxv4=XrVWlX|wO3_fkrV_EwFZ97JGpsFEeQ-J|9O*Pgh_|N&Y7FOZ zgSvc&RWR0fl^kz=I?j$KR9n&rLCWebkFrWrd7t1*;;d$h%uG#riP*iZNs-AI%)@#@ z(NyCCt@hF_a;6k&++g7d|MIi-T8w8QJm-u;l;H_eYuTD&wxSEC@w**$x^i-Gc6G7H z#|69|8<;};9gD*dr)ph?LuwE$jK47(#ILHVnh?6rf+wQgjt^XhqvhBmeUKa!K&J_0 zM$%4X&fI4(sH_}_BG`7cOox&KCDQ8)TTN5<$_-MZ3kiAOkSB^V6D*2TbZ$I(ocyh_ zStz$I&d~XhhQTUjsbv6uxtUR%WIAf=a@wW17kNJ6yaGk}cb~Qc{h~jbh+#I2QQSeW zA}NfzM({uxErLb9LNFgYoP1(1Cpu%c5b80xic=w~KMp%MM``IJttz<|=OL`wsH@JE zY&Efn9J<$szZUxuB+|9`DFiEnXo43IAMnenHH>WAp3ho5jkEPvLdpEKu4Nt?f|UXc zr-N)3et;P#3uF^-Zd@gZqdH))mdh&If2rEdGsj`z>tL`t61jH%M%&g|4e}T77abI} z?=3i}f)QKby5R3_>rDEA-w;gwBN2Vmdpnw^RTk7KadlENvE?_2gf33V=UgBrok}}- z-#8T(px5d-wIT0#xG{=;=5qig#^-fqI+26d_^CgJ1X!zhvv-&efRL_W=o9j^DhjaN*xuWT z$-T&j!H9AW$tqUNWg*-9$in_rEb^n}WTzT;iQ+fTjXpesUgF@P2O!er$PB%f3=iMh zN)}@b;rBf0k;C8=B+C_yc=eo+-k#Ur6ClZ5*=SRkG{3xIeeVV;iI~?BBF67P9yBZt zMigW3+=mS9aAaHCtXt3z_WC0tCw^^2s1a}-76u9#jpkiuj~|InLqKlVjU#^`?0A1` zS=OZB0GEWW#3=SQWj#ddG}tHCLX#;1V|Moufs~eBK4fT6@S3-rp=Yu(8KM_H2MV0dYNPp6UZXk@5UR!YiUIzRG7F2d7dFY}Zb!EaEibvzjp z6^+#v5PNR)oURViQor`t!AN-iZ;$(V;)7fwFBid%eHS-f&FGk`QQttQ`~}UhEGjTX z3rBm|D0RNz9kHJT~qPO4`uam~yf0rcoH3I4HFpSY%OUrD?^G2j%zS7qOy4QHW@d zV-5Tt&DL7If}W4UNCwUi%)=12pnjo!;|iK#ueM%|?__Gor`zDLU#ynoEDDAfoR2wa zLFE214m1bIKD1!?U#p9xI5@C)dcZQaBZjt60Q5IdW25&3u>EYUviX)0VMJa6Yc)AW zFYyUhs|XmI^#=F`Em&54-pgy$&CI(Un*}yfsxzTUf0xo_DT+Lglxf5j1}3tW@G7ca z%TGT}5T{^Nm+B>B5;CGa(K~y-(!P4IzTT9bHWVl{Z5NobN0iA6Eio+8P~DV%87_y$ z6%c=+7YlKke3-_M;xJh3vJK25Si`siDG-Gf@Xzy8%0zWx;Qlfp2(ox^XD*xI^MBzj zg7@NrSyb=V5U7j>1|sFCJ1~lRJ{-7z4+Ay_bcT=O+LWeo8Alv-2n%FYXyP>L_Wz#? zP|&N~3GpH+T4oX0A&mMK1k>fa$ITHql74$<8KKl6tJTI@q z5Cr1+Uk%Z9^Sh@5u(mrdOqkX)kKg^I5db>31x(P2-+WOZS;hh2^}8koF~PEq!fxZ*w%A^B_g!?SZP>!Woydm;VHj*HW*Ij~FKirc|qnl9X5# z9Aik`d6BrDE*>GRV@h}rMX+Gi54DWDK!p;;C6m}DTaSjPyn1vqJtJpt;w7;8Sfc*> zZ=*lan6BZ>{ZkifwLg=;?XJA?z@D5h^zfP0@Vp+;pG5_O-?fD{+Onk<6yFav*q2A3 zlPY~)r*KA;bcLQ4$2d<7UbHe4v@$lkR$>{jdKUL{1Ik65o1jwKYUZJ$(9$p*K~$s? zq=Dy#f;l9Hj_!_htvA#18Bd?a0LX7Knnuymi-n0fNP-U!_9Cf)lE{g9bA;;Xd$`=* z-HD~k^;LNXJ7bo_yP}6&>%Jm(>f$=CXqo$c4-0&ok?Ju;wi#6J8;sTg8llQuX)GxLmF_q*3yyoFr|`yvY8 zSws13Id-N%iibzn8|)~AvUWobsrq5mFAw8s`}Y3!CNx99!|GYM*@!o?$jMHbKTBBg z+|P(JXCT>^L$%_?uln?xv?S)6Oc92vT@9YiF&do!+GN_jtuso(5}Foq*9|aHrsaj) zMLK)XmSqOe4iWL=0ifAuqf=|SX&K0A_;hpl4?3m~Yf-rv`!5p4E+&?U*=Ggzdf!gO$GXqo1*VKYwAr?HP1 zT)@=+_SEqJ^@K%4jMn|)y+2MOBScc84o60DY(h1y^lw&M5)KNZ%}09x%z~wMqa%f{ zttoy<9F2SmDEdkMhGZlGEM-aixsO=<$FgEN2Q1WhmjVZt(Trp7L=3g@sci6A; z=PB0+$Dvl6c9j^rH)Yk;+Wm}wV)F@So?1raV)6PB#0x&Zlz}FbFHBvp8`Cr2vNrf! ztZlF;)n+G!A+n0Gnz!trr@ae2Ve^t+Z*a8`mk@;2{-K(P8<{nM#)7?hqPy2ETibCA^f|3 zbyIN;3MA7x9Sw!ok*V*S{C$0u#pi-p=7rHc$*LF8q#9=DcRj{KYB>*3G9U&jG~oyG zbohlwFrNO!?-b~l$L^v8$J|b2U?bZ@_mfz+3A} znrP{9@xz8YQZ~FFhp=!?RG@{N%yrPWxBHIw&v~C>PSVwdLi$zrEoa=X$gUVyl4f!u z>#5AGU)qJ%>*os;1CI4CTO_@5(l&^IFzm>Fq2IQ|<>efRCbDR4rCp4)LM7%!Kl}W* z?g$=Wj;zdGt~q{LivO6pFYX-xawd?RGw2Lp|HH-@OqqO#aSV-#xHVJIdXNSJJb*VN(3k$B<39E z(LmR@d`4MGt5yX+?Y_^wt7ZD*iYbygKU zxY^AjT26fJL;`W!b(Rb`kYgibHZ}B6-s5H{Sx8Dr2|d*<1r;Ub&P{2YyhL;{&1Da03k6hMnGUsPkgfT;j~aH znJ|t-;}AY*B}CVW)Ndz=Ck$D0DE2D4=awh=U_*kl^5;5x45<*KmI;33?pG>Q0MZuU z8Ii*T8*-a-Y**onq4D9uCEDyTR>4K4Sy3u3i>H(sFsT1Z+BlUKxzvKk7s1p0)TQOI z6Fc*YD9-3}cY;Bu;6G4#SnrtpHq+s8M08Nr%Q*Fs#WqK@f&9yCZWWBR@+kXRE=JOC>SR z)gKu4WaR^zH(@+YoH#ISq3Og{>x4&Sawx5l0RLxGX%JNQlb5Gw#?Ehx;AL_cw9Q8j zk0Ko2!*@o+jPUZ$$5Zeo$&KDQsRGwzhY1YXGb(jE184&f)KQj|fozKprgOwU1pz*i zn8!XVj=vcC!yjgy`Vt+(cz}GaHt6uTH)t^#Kpl48fJ5*~EjJhN!YSjyl&CLXDPp9; zro`5jUPy3>$m5usk7&KMt@*)0RzF8DZLi^bq|=4XEVjI%@d+VwbpY0Yu4_3n-EIsEu&QqdZyly zYMoXl%~3&puoyKOO<&})ya z&Z6y`V0|;!4N6h4Xh4ZgWw*y}ZNaWqlsSJGe9H_$I(}={+~-U9e;;F$^U=kO?CACT zOIl;Ix3yvB(1_O7_O{zyT*88Q1;Fm_=qkb1hCDTWLtehtY(&1_8J9${nVLKsZeeAR zUsGNqC`3LiueFugl~du4JaT$Dl#ON<0WkK@rhFY++YgVZ5~gpD69I&R_&vm^Vpo-6k&_r=J*bbjxNrQ#`~_jYtZvf zEzw18EU0s0&J0_S<8wOUbCmQPn>6$B6=``)#~!uE5l1g(sc-5+cz9GZiItXV;!e$4 ztq9`W9^2MUABtIwl9KX9UOAnHkMD6iR)pK%Hc)h4uqBi!pH75sr=7>PEJ! zH(;HJz(eVr#ib;FgR?~UvP+B*DD#_sFNMMI`90NU4IeQwsN1i%dexW^T~AW#5{it& zIZ9VVFLNSAW_Orw(L8k95zX{=FuBmt|M9a*3&r;zh3!Iy_anD(0>p~NhD834K7gznx4$0se&tcN7=XUyEY)XNsKt{PL-lu`OU*v@skK#5 zV%+$$7TVvAa-1GY!EgKy4sbC7Pz;4U`opWC4WaIYaol2!g<`JoYFM~ognTNC*=BN} z^BB_@V_6z9z&%s(inq!tP5S;>rj)7v^wWComvtb$CUxRE3{br=U5!y%QJlT1P0nVU zA6LD_(Bp!tF}9E#YrvM=g|=^i@WKv3!!b1w61woLSN(~^>tm0JB^yMu%l?-eQHn@n zRJ1N5t(n7J5hq^##w zXDLI@bzLt-f1j6}$$}efFUEVs9;M9nyCZna_@G4g6+RdLzIqgP2M%8Vnin7$HlBs!SSCUk{Darp^c8%)py{U9JV7bsXmxUMBoMk!+4 zzfI++GHezGC?Axq#l&EQrn<&2J0C{GVTTy+Xr~0O?b;0LQT%%D*6`}NGCBRI?=>bC zerOpTEi7(*-iC?|pcw=hTP_=}{@&ifWQbN%AXV&F_li1iYTQr14mV@7>g!svvxv2_ ztL!ci66=7UGerXY_bF9i%x1AVh`~-jI#~K`DZ`*2e;%Iy#2X#l$HaFC`5^WeiP8X4 zRY%P3Z>3n+63FNuKZ^vG`f=4@w=9(x{z2?&r@CG4aHyu%pUAIr@U9zMrvalLuXt(i zLyg#v#;@7S{3*XQemo;~Xoh-lMWT`L&+a=VqKUjr8vdRI<@!G{nCV5}GBfUzHZwvq z?!zJSVHL$0*sv{&TGZfE0jb;FjNfaVWzuGWDWPBa$G|~2t?Ed3XDGRi+!>@(GaAU` zD7zb-I!6RC#1f~Z8+p7Fk%FU2pJod=81rf1a2e&J`}aTh^7Rw#b!IZvvdSf&YMw_h zwmcB3t!8M)?gj^CsE~|52d3M;y*%z98KRu5wPjYbT#Zv*JT)djId8=PLxofaMWgH! zz9c?nb``$cCdY%C>ufN3q!`Qqg~0|ECKNp!O_=V{ny0yIWqev1eeI82m>=93x`!-r zW{1I8O=0nicE5JyN-qfc@ASWSV_Cnh0#YFM!WO^m{1VTH5^KlOp+>Rw-}(D%)q07} zKEp}>!X+OU6%_@DL>kwMcFXn2_X`)8f|6IHmF9suo`BAlRA>co`;$L8JBKhS`1zM= zT;l-EV`TR)3b4Ag;D+MCq%?c?i)oxO8dPW{GNvJ;lc5 zCv1W<#7>oY1H#)MzUY3{`la=Q39KbgUVo_9?%Px*afWB$t6YL|B1GnU5tw?s1Skr} zm-_n@kZ>Mu2q^IU=^x?INU>MY_+0jGeh(AdvhqP#_&_pg_obYASM?q8ILTk!( z1;GkMcE`PLfoAw+dh{S5&?f)+0_uO#13X9}`p(_y@>ScxbYY|}et!e85aU@bpryqC zNEO(bNLh^XIG~tFPl)IQ#L4L|2TcP%8=1z)NWyFrDLLBX?VEPgu3DF8bse`}!Ux8Y zPm)FAvCh9>>8_jfN3o}2Pv=X=0!S%}kV`ILK*sVBP+{VVJ0-@ibX?uf*YxNS6H-!& zI9;|;&z9=sEp6w1CBuUS=B6y+0sO*c?{B4Hty!ET?$KX!`5{w&C;#e`1OO8%;9|&x8(|!p21-c(s)g^Su9=JmUiAsim_dzV>p3Ht) z77K;(c|#XM1Xd`#7C6q-TB-J^=Th1)W?7*A-xHi<$m&a)7`IvII;`xlZYCbA>&!Q?IK5)e26y8*Ts=ScHRm|XgDRqE;YJhnA_83 z%{7SyY&Omu|0gTWkz}V^(?iv2Q(5V)ASXu6@+{a8c_0Pm+Tu@e!~~aVq0nx>qY#Bi zdD%>$GfUG}TEVZzS6eM}vs=)1AxxFEC9FHfJ?2s+TT6M!&5<|%{_FfoyQ*0z21P9f zA&6BztSF`5$G(wg{FhAG>EG1&zbyW{~d> zb3LgKr<5wE^K;V$e++jQc ze{|x2>mU~u4ZLI8+}8n<1e@1PWzPWPnUfFL61a%iGw5A|81fJ zU?_M;HS=Fm=EVoIMsH)=EEv1tdGzxAsptv&4H~*AH^A$?jo!f|NVg3vB%1QcUzgnN7nwE}GFUC)| z4rT)A{rDM+^QHvnPPF>MbL z;Qh;ioSFJ5IT!8u30V~1d&kvDuvx(4%ogaU-q0SJopIURc7(7z_%m|?ul(PL$SEi& zxVgCjTMOVZ>Cr}lmXnZxdE*C^W^TKs!OU;u&PJ7$zxPgi>iQ;QS=|9GehdT>#)#Q9 z>iIroBfviAh0YpGCGn-m1!Dbs*ODA}CJI(2)8TiFbz88T?&J1A>^47A4S5z*m{6VN z6x0sNjMSg6b#fMDlqKj{F^-TEnEiLwaGwbHe*|U*IC4@Jecu~U1(pFDfdj3e!H8&A9}Oq%wdDvo0Is zbKR<#lNXzsP&D)Fvx%gP>S|^5c-neIg*p|@)mnx?X8jDqF*g|5wVBPl1#kqke2t9M zJ_2|UDK$@Ejm#+fx;K$40?0>ABqlskLY5%Gog?>>6a*~VPLNB>8Jr1Ilpykb$J-MV znEc^Q=p@dM;-*kOm2J~#qaWtm;aIX;Sa`46*$9CCP{$%BRCu~Adv4Ds^Pu;bL1Lp= z7o{5-xQuEb9>ROc8F&b>;Hu=&V0wfimoa0UqL(3FE!E+{D$-!la~9pkNjXS7=EQ&C9ODvbEJ{xu zDt(N;;SFRfN|8|>$<>y!zytOT%Zi__qHjva!r?=lrGTQ)Ew)qvFun8}9U-{e zC)M($P&6-Tk)_ubEZ1r*(1bN-(nE^LBmcZT@7U<99!2Ql)mg*7(5J1k8WfIba%c`4 zN=@uPYG^KP*y+p;#5zBt(vTJwm|9{i7c!+U4}|RJhdI8`qpAHMoJ#uay>h-}`IK6( zquonkmLw{V9(4|&^bp!fhRL-8S*(!G{n2zP&HEYlb3;suE2x0q8*U*eHwh$s7%Jrb zs4ppHnibyz?R}U38d8mZ0TljQwBT*{Di>AtTy{(GX|XasrhI|G)8)aK_dEHH;9nnY=^wvSsI*pwA@fp#)q$2`jZ6F8$GGF&3_Y& zr#mM%z?V|}sU1472@!{}QO?iJ)iyh|$Nus4etowcgL)$(x!&Z$QvwHQ2w&Pa0L}9o zM8a}S=U3f8q*Ts(3j!gfq4H!i2r?`|bgS6i~*g^%%$O2y2R|duPy)~e)n9IZA`Rps#42gYerr5=~bNp(t z#$3^F=v67~y!4{RGl}5c1eg9IPQthFj8g5+I9~%kDEln7cpn8`kZd!vY_~$o7phv zgT9r{zfdo%cE5j#XmeC=_#>U)&HDZA6@zAu4;j}QNx&4YF^tUr!6?Kzq@bI%{Q>IJ zQKeB&l<6bI(&{kk$`m9LwNF1F2n2l#(j8yKsl>)({(1u-Ztqqyn6=Glp0#f#tpGzV zb!D4nh#rEjTV)JR)p@l1!bxg+YtwHmI4Ft3>v}Lb7*DlaZx@*sWYMzMogb{W!{Z<+(V+{7)~Auo2owV63DyQn9WhXoO5bLDdOru3DDRxA8yKf z;&^QTn3U>eVIEG!K!GNIzcie-L=!HbtkP*|^VjnNif(qL1p5R<3ECoL zYPHF>8;myF^WE{n#Dk7{xh~%z%^={0T==6CD&qo}A~E2nhDHROwt87RUV@wkMvkg( zG*g6K74FYgiMVYK>a_%<)T01H6>AEQ`1Ln{IBAXLN5MLGU_{&&jp^I)+sT+Gg+QqS z7C!Yd4KdMy{{EkML*bnqah74PRBw%d0J))E@Gbm;PT=-v&StTi<|nn3dvsUX#etaD zMtkO|LmDd}-Ir_!PfgE}5ibh`T-#uqokV)f6(6AMlmki~OLl${U6F+@yDy?G z^i3K1(hAOfn^ZKRa=_+a0t`xg3m9mGBdjh75%3z|NsOjbTZIZo_KEtiP2YW` z)Prx0o_JV7ufoR3jNQCx{MCGZ@JwQPzAe4uAuh}@f<-!cgt71^2bSFbyky(vdq>GN zc!wvjncJzeHLe|IIo6oincAJ-zc*o#ktx2x2SP}g5_gzL^vHbuDS?0tFByT|r50g? z-O~jt0CciDTeFaNM+vmY>D_6*e zTd)nVpw_-~h#Fb6zP~*m{h7<-uC8ZFFv2eaoJZXW4!m(6X33DZsl)0qLKNM02IBz> z-3b7>9LhdWbw`x6g7u{VW1_t1O))T}#7%I&NSKL7{#YLJEF*y#2ed+T==q5?j&5KD z$79hwxV~ZeZj`oB>~=igO`nmKMI0fZ%%+|2Bas{xbtxS%VFKi0hz#K9RbYNo`26{^ zoZM1_1KDg;>ahsHcO&s6Vm@cU_9|s*iU-0Hgeiq_as)UI6!!}sH8r(#4l_oL-(mQl zfyE99n_j8WanocVhEGF3OUP%Qf@=~gu%ZT<;1hDs3fV`iX$g9JAD}z{LK(dW240L= zY&NSZ-#|4P@P9f@0~Vq@GvLZ6_~t>Oaq<7`-%(D0p*RHgJ21;{{bvNeUsC+%>#ioR zMuO2dH8|DpWfKnkXA4)*$ag)QuCrOdCFH|MM&SO>2X4w`iuaG4xj$c5M8;D1&m=yi z0z4!+3(FTvU0}SkE5vnSQYSS6kqJS-3XxhP2g1q~@lzp3P}>r@o}>c*yH-#LE_dc> z!@B=JFX%!+Vd14{R|w4F|2(U-8~^95=bi765D@S0krg5=fb}2lAGU{vxIv*wUZ)@5 Q1kOOnN`8?j7dH(0e_HocYybcN literal 0 HcmV?d00001 diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json new file mode 100644 index 0000000000..b9dc53d9a5 --- /dev/null +++ b/packages/skill/skill-badge/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-skill-badge", + "description": "Bundled dsh badge skill provider for DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "assets", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-skill": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/skill/skill-badge/src/index.ts b/packages/skill/skill-badge/src/index.ts new file mode 100644 index 0000000000..27cfd29354 --- /dev/null +++ b/packages/skill/skill-badge/src/index.ts @@ -0,0 +1,60 @@ +/** + * Bundled `dsh-badge` skill provider. + * + * @module @deepseek-ai/dsh-skill-badge + */ + +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import type { + SkillCandidate, + SkillDefinition, + SkillProvider, +} from '@deepseek-ai/dsh-skill' + +const PROVIDER_NAME = 'dsh-badge' +const BUNDLED_RANK = 600 +const SKILL_BODY_URL = new URL('../assets/dsh-badge.md', import.meta.url) +const RESOURCE_BASE = { + kind: 'directory', + path: fileURLToPath(new URL('../assets/', import.meta.url)), +} as const +const INVOCATION = { modelInvocable: true, userInvocable: true } as const +const DESCRIPTION = 'Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.' +const CANDIDATE: SkillCandidate = { + name: 'dsh-badge', + description: DESCRIPTION, + invocation: INVOCATION, + provider: PROVIDER_NAME, + source: 'bundled', + resourceBase: RESOURCE_BASE, + rank: BUNDLED_RANK, + locator: SKILL_BODY_URL, +} + +const provider: SkillProvider = { + name: PROVIDER_NAME, + list: () => Promise.resolve([CANDIDATE]), + async get(_candidate): Promise { + return { + name: CANDIDATE.name, + description: CANDIDATE.description, + invocation: CANDIDATE.invocation, + provider: CANDIDATE.provider, + source: CANDIDATE.source, + resourceBase: RESOURCE_BASE, + content: await readFile(SKILL_BODY_URL, 'utf8'), + } + }, +} + +/** Cordis plugin name. */ +export const name = 'skill-badge' +/** Service required by the bundled provider. */ +export const inject = ['skills'] + +/** Register the bundled `dsh-badge` provider on `ctx.skills`. */ +export function apply(ctx: Context): void { + ctx.skills.registerProvider(() => provider) +} diff --git a/packages/skill/skill-badge/src/invariant.ts b/packages/skill/skill-badge/src/invariant.ts new file mode 100644 index 0000000000..c087d5917f --- /dev/null +++ b/packages/skill/skill-badge/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-skill-badge`. + * @module @deepseek-ai/dsh-skill-badge/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-skill-badge' + +/** Cordis companion plugin name. */ +export const name = 'skill-badge-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the package owns one immutable provider registration, + * while the skill registry owns registration uniqueness and lifecycle checks. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/skill/skill-badge/tests/skill-badge.spec.ts b/packages/skill/skill-badge/tests/skill-badge.spec.ts new file mode 100644 index 0000000000..e4d62f1c89 --- /dev/null +++ b/packages/skill/skill-badge/tests/skill-badge.spec.ts @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import SkillService from '@deepseek-ai/dsh-skill' +import * as SkillBadge from '@deepseek-ai/dsh-skill-badge' + +describe('dsh-skill-badge', () => { + it('registers and disposes the bundled badge skill', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillBadge) + const resourcePath = fileURLToPath(new URL('../assets/', import.meta.url)) + + expect(await ctx.skills.list()).toEqual([{ + name: 'dsh-badge', + description: 'Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'dsh-badge', + source: 'bundled', + resourceBase: { kind: 'directory', path: resourcePath }, + }]) + const loaded = await ctx.skills.get('dsh-badge') + expect(loaded?.content).toContain('Preserve the badge\'s 121×20 dimensions') + expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: resourcePath }) + + await fiber.dispose() + expect(await ctx.skills.list()).toEqual([]) + }) + + it('ships the official 726×120 PNG unchanged', async () => { + const image = await readFile(new URL('../assets/dsh-badge.png', import.meta.url)) + expect(image.readUInt32BE(16)).toBe(726) + expect(image.readUInt32BE(20)).toBe(120) + expect(createHash('sha256').update(image).digest('hex')).toBe( + 'f2c4f5ec9cbe847c0c763545c4d839efa8485bc74203733d0a0e8259f233c653', + ) + }) +}) diff --git a/packages/skill/skill-badge/tsconfig.json b/packages/skill/skill-badge/tsconfig.json new file mode 100644 index 0000000000..cf6642f69e --- /dev/null +++ b/packages/skill/skill-badge/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../skill" }, + { "path": "../../support/invariants" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 208e43aa5f..ad6bd2250b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,6 +365,9 @@ importers: '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill + '@deepseek-ai/dsh-skill-badge': + specifier: workspace:^ + version: link:../../packages/skill/skill-badge '@deepseek-ai/dsh-skill-local': specifier: workspace:^ version: link:../../packages/skill/skill-local @@ -4846,6 +4849,18 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/skill/skill-badge: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../skill + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/skill/skill-local: dependencies: chokidar: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 6bd613c2ea..750abbc902 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -105,6 +105,7 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], + '@deepseek-ai/dsh-skill-badge': ['assets'], '@deepseek-ai/dsh-scripts': [ 'lib/dev/tsdown-config.js', 'lib/local-plugin-loader-hooks.js', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1dd7973fd5..98ef82bb8e 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -287,7 +287,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'skill', title: 'Skill provider registry', mode: 'seam', - implementations: ['skill-local'], + implementations: ['skill-badge', 'skill-local'], consumers: ['tool-skill'], note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.', }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 041972cb9f..e66e9a73d7 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -111,6 +111,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, + 'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, 'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 4fcf71b680..8c11b48a46 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -135,6 +135,7 @@ { "path": "./packages/ui/permission" }, { "path": "./packages/core/tools" }, { "path": "./packages/skill/skill" }, + { "path": "./packages/skill/skill-badge" }, { "path": "./packages/skill/skill-local" }, { "path": "./packages/skill/tool-skill" }, { "path": "./packages/ui/tool-ask-user" }, diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 455ecfb4d4..426ebedd8e 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -49,6 +49,7 @@ export default defineConfig({ // The assembled Web snapshot executes generated client bundles; source // mode remains the zero-build path, while lib mode requires a prior build. ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? ['apps/web/tests/**/*.snapshot.ts'] : []), + 'apps/cli/tests/**/*.snapshot.ts', 'examples/*/tests/**/*.snapshot.ts', 'packages/sdk/*/tests/**/*.snapshot.ts', ], From 4dede454ed6ca776e7efa9ff5e57491348be5d26 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 6 Aug 2026 18:54:53 +0800 Subject: [PATCH 011/100] fix: address dsh badge review feedback --- .../feature/2026-07-05-skill-system.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-05-skill-system.md | 6 ++++-- .../feature/2026-07-05-skill-system.zh.md | 6 ++++-- .../2026-08-06-bundled-dsh-badge-skill.i18n.yaml | 4 ++-- .../feature/2026-08-06-bundled-dsh-badge-skill.md | 4 ++-- .../feature/2026-08-06-bundled-dsh-badge-skill.zh.md | 6 +++--- apps/cli/tests/dsh-badge.snapshot.ts | 4 +++- apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/skills.i18n.yaml | 4 ++-- docs/core-data-structures/skills.md | 8 +++++--- docs/core-data-structures/skills.zh.md | 8 +++++--- docs/event-producer-consumer.md | 2 +- packages/skill/skill-badge/src/index.ts | 12 ++++++------ packages/skill/skill-local/src/index.ts | 4 ++-- packages/skill/skill/src/index.ts | 3 +++ 18 files changed, 48 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index 6bb4aac193..a98beff699 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-05-skill-system.md -2026-07-05-skill-system.md: dd2fb1d22949f55ea7cb2c9f280e7cfbfcbbb226 -2026-07-05-skill-system.zh.md: 96656a8e1dfc2ae1ce7301ba29e7739349b6aab6 +2026-07-05-skill-system.md: a998d70ec934aed4bf7ce32aa711abd47b508a1d +2026-07-05-skill-system.zh.md: 4fa7c4fd657c2f41f16b30679ec95e61a75f8a0c diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index dd2fb1d229..a998d70ec9 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -14,9 +14,11 @@ DeepSeek Harness uses the same primitive so project-specific review, plugin-auth `@deepseek-ai/dsh-skill` is the pure provider registry (`ctx.skills`), `@deepseek-ai/dsh-skill-local` is the shipped local filesystem provider, and `@deepseek-ai/dsh-tool-skill` owns the durable session catalog and model-facing loader tool. `dsh-agent-spine-demo` loads the registry, local provider, and consumer by default so TUI, headless, and ACP apps get the same behavior while embedded or remote providers contribute skills without changing the registry or consumer. Its `skills` config forwards `registry`, `local`, and `tool` branches to those owners. +Dedicated packaged providers can contribute immutable skills without filesystem discovery. The shipped CLI declares `@deepseek-ai/dsh-skill-badge` disabled by default; enabling its composition row contributes the official badge instructions through the same registry and consumer ([decision](2026-08-06-bundled-dsh-badge-skill.md)). + Provider plugins register synchronously during `apply()`. Provider membership is direct effect-owned state: registration and disposal invalidate completed catalogs synchronously, and discovery reads the current provider map on demand rather than observing registry-change events. Provider catalogs return ranked candidates from awaited `list()` calls, where remote providers perform initialization, authentication, and discovery while honoring the lookup abort signal. The registry validates each candidate, resolves same-name skills first-wins by rank, provider registration order, and provider-local order, then sorts summaries by skill name for deterministic consumers. It caches only completed catalog snapshots and retries when a provider/runtime revision changes during discovery, so an unload cannot freeze a stale, unresolvable skill into a session catalog. Runtime `ctx.skills.register(...)` remains a convenience for embedded in-process skills and uses project-over-user priority; `runtime` is reserved as the registry-owned provider name. -The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. DeepSeek Harness does not ship built-in system skills; embedded or remote providers supply additional skills when configured. +The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. The local provider does not synthesize built-in system skills; configured bundled roots and dedicated providers supply additional skills. Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable` are optional. Names are kebab-case. The invocation fields project into a typed nested policy as defined by the [independent model and user invocation decision](2026-07-28-skill-invocation-policy.md); the parser rejects the old camel-case spellings. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset. @@ -48,7 +50,7 @@ The data structures and catalog/tool contract are documented in [skills.md](../. The agent-core spine includes one catalog contributor, one local provider, and one model-facing tool. Skill discovery is cwd-sensitive, so callers that create agents with different session cwd values can observe different project skill overrides by design. -The catalog is deterministic for a fixed root set and runtime registration revision, but disk changes are not watched; discovery is memoized until runtime registration invalidates the cache or the process restarts. +The catalog is deterministic for a fixed root set and runtime registration revision. The local provider watches configured roots and invalidates completed catalogs after relevant disk changes; runtime registration and provider disposal also invalidate them. ## Deferred diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index 96656a8e1d..4fa7c4fd65 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -14,9 +14,11 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 `@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责持久化会话目录与面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 TUI、headless 与 ACP(Agent Client Protocol)应用获得相同行为,同时嵌入式或远程提供方可在不修改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry`、`local` 和 `tool` 分支分别转发给对应的所有者。 +专用的随包提供方可以贡献不可变的 skill,无需文件系统发现。交付的 CLI(命令行界面)默认将 `@deepseek-ai/dsh-skill-badge` 声明为禁用;启用其组合配置行,就会通过同一个注册表和消费方贡献官方徽章指令(见[决策](2026-08-06-bundled-dsh-badge-skill.md))。 + 提供方插件在 `apply()` 期间同步注册。提供方成员资格是由直接 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。它仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧且不可解析的 skill 冻结到会话目录中。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 的优先级;`runtime` 保留为注册表拥有的提供方名称。 -本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。 +本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。本地提供方不会合成内置系统 skill;已配置的 bundled 根目录和专用提供方会提供额外 skill。 每个 skill 是 `/SKILL.md` 或带 YAML frontmatter 的 `.md`。`name` 和 `description` 为必填;`whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable` 为可选。名称采用 kebab-case。调用字段会投影到类型化的嵌套策略中,具体由[模型与用户独立调用决策](2026-07-28-skill-invocation-policy.md)定义;解析器会拒绝旧的驼峰拼写。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 @@ -48,7 +50,7 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 agent-core 主干包含一个目录贡献者、一个本地提供方和一个面向模型的工具。Skill 发现是 cwd 敏感的,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。 -目录对于固定的根目录集合和运行时注册修订版本是确定性的,但不监视磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。 +目录对于固定的根目录集合和运行时注册修订版本是确定性的。本地提供方会监视已配置的根目录,并在发生相关磁盘变化后使已完成的目录失效;运行时注册和提供方释放也会使其失效。 ## 延后 diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml index bdc103ef46..222ed1ebbe 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md -2026-08-06-bundled-dsh-badge-skill.md: afe0b21d64a414a9e78ef55459a42c0d3817e3fd -2026-08-06-bundled-dsh-badge-skill.zh.md: de1ec989570b07987b12f0a291c84643aa5531fd +2026-08-06-bundled-dsh-badge-skill.md: 512f67ca347ca311a1f80fef932f6af8c91b0fe9 +2026-08-06-bundled-dsh-badge-skill.zh.md: 88fcadf66944cb91419441be3916ae04968be663 diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md index afe0b21d64..512f67ca34 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md @@ -6,7 +6,7 @@ English | [中文](2026-08-06-bundled-dsh-badge-skill.zh.md) ## Problem -DeepSeek Harness has an official attribution badge skill, but keeping it only in a developer's personal skill directory makes it unavailable to other DSH installations and gives the shipped application no explicit opt-in point. +The [Cordis tutorial](../../../../docs/cordis-tutorial/index.md) uses an official “powered by dsh” badge across its pages, but the shipped CLI has no reusable instructions or explicit opt-in provider for applying the same attribution elsewhere. ## Decision @@ -18,7 +18,7 @@ The provider uses the bundled rank after project, custom, and user filesystem so ## Alternatives considered -A Codex marketplace plugin was rejected because it would install into a different runtime and would not participate in DSH's `ctx.skills` seam. Mounting `dsh-skill-local` over the packaged files was rejected because filesystem discovery, parsing, and watching add lifecycle machinery that an immutable single-skill provider does not need. +**Mount packaged files through `dsh-skill-local`.** Rejected because filesystem discovery, parsing, and watching add lifecycle machinery that an immutable single-skill provider does not need. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md index de1ec98957..88fcadf669 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md @@ -6,19 +6,19 @@ Status: implemented ## 问题 -DeepSeek Harness 已有官方署名徽章 skill(技能),但如果它只保存在某位开发者的个人 skill 目录中,其他 DSH 安装实例便无法使用,交付的应用也没有显式的选择加入点。 +[Cordis 教程](../../../../docs/cordis-tutorial/index.md)的各个页面都使用官方「powered by dsh」徽章,但交付的 CLI(命令行界面)既没有用于在其他位置应用同样署名的可复用指令,也没有可显式选择加入的提供方。 ## 决策 `@deepseek-ai/dsh-skill-badge` 是一个原生 Cordis 插件,会在 `ctx.skills` 上注册一个不可变的内置提供方。该提供方负责 `dsh-badge` 的摘要、指令正文和 PNG 资源基底;`dsh-tool-skill` 仍是面向模型的目录与 loader 渲染的唯一归属方。 -交付的 CLI(命令行界面)组合将 `skill-badge` 声明为禁用。启用这个现有配置行就是显式选择加入;禁用它的安装实例不会公开任何徽章 skill,也不会获得任何模型可见内容。 +交付的 CLI 组合将 `skill-badge` 声明为禁用。启用这个现有配置行就是显式选择加入;禁用它的安装实例不会公开任何徽章 skill(技能),也不会获得任何模型可见内容。 该提供方使用排在项目、自定义及用户文件系统来源之后的内置 rank,因此用户自有的 `dsh-badge` 定义可通过注册表的常规优先级契约覆盖它。提供方释放时,注册表拥有的 effect 会移除该贡献。 ## 曾考虑的替代方案 -未采用 Codex marketplace 插件,因为它会安装到不同的运行时,无法参与 DSH 的 `ctx.skills` seam。未采用使用 `dsh-skill-local` 挂载随包文件的方案,因为文件系统发现、解析和监视会引入不必要的生命周期机制,而不可变的单一 skill 提供方并不需要这些机制。 +**通过 `dsh-skill-local` 挂载随包文件。** 否决,因为文件系统发现、解析和监视会引入生命周期机制,而不可变的单一 skill 提供方并不需要这些机制。 ## 后果 diff --git a/apps/cli/tests/dsh-badge.snapshot.ts b/apps/cli/tests/dsh-badge.snapshot.ts index d78f4c743d..abd66e6f79 100644 --- a/apps/cli/tests/dsh-badge.snapshot.ts +++ b/apps/cli/tests/dsh-badge.snapshot.ts @@ -34,6 +34,7 @@ describe('dsh badge assembled snapshot', () => { expect(enabled.stderr).toBe('') expect(disabledSnapshot).toMatchInlineSnapshot(` { + "catalog": null, "result": { "content": [ { @@ -46,6 +47,7 @@ describe('dsh badge assembled snapshot', () => { }, "isError": true, }, + "summary": null, } `) expect(enabledSnapshot).toMatchInlineSnapshot(` @@ -169,5 +171,5 @@ describe('dsh badge assembled snapshot', () => { }, } `) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS * 2) }) diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index 99379b4fe4..0bf4a92c8b 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -47,7 +47,7 @@ try { arguments: { name: 'dsh-badge' }, signal: new AbortController().signal, }) - process.stdout.write(`${JSON.stringify({ catalog, summary, result })}\n`) + process.stdout.write(`${JSON.stringify({ catalog: catalog ?? null, summary: summary ?? null, result })}\n`) } finally { await ctx.fiber.dispose() } diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 449addad8a..0cfc956221 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1372,7 +1372,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:170`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:173`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5044b0e5ce..cd54572d91 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -670,7 +670,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:188`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:191`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b3d81d61b9..2df5bbeb0a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1897,7 +1897,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise/skills` | | 600 | `bundled` | `Config.bundledSkillDir` when configured | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not synthesize built-in system skills; deployments supply packaged skills through configured bundled roots or dedicated providers. + +`dsh-skill-badge` registers one immutable `bundled` candidate at `BUNDLED_SKILL_RANK` and exposes its packaged asset directory through `resourceBase`. The shipped CLI declares the plugin disabled, so enabling its composition row is an explicit opt-in. Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete without hiding readable candidates from direct loads; project-scoped watchers use a configured bounded LRU. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 7d69b84f7b..3f8c034ec2 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -2,9 +2,9 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill) 拆分为三个包:注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描并监视项目、自定义和用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill(技能)能力族](../../packages/skill) 包含注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地提供方([dsh-skill-local](../../packages/skill/skill-local))、可选的随包徽章提供方([dsh-skill-badge](../../packages/skill/skill-badge))和消费方([dsh-tool-skill](../../packages/skill/tool-skill))。注册表合并各提供方的目录;提供方贡献本地或随包 skill;消费方拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 -源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 +源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 ## 提供方注册表 @@ -72,7 +72,9 @@ interface SkillProviderControl { | 500 | `user-agents` | `/skills` | | 600 | `bundled` | 配置了 `Config.bundledSkillDir` 时使用该目录 | -项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 +项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不会合成内置系统 skill;部署方通过已配置的 bundled 根目录或专用提供方提供随包 skill。 + +`dsh-skill-badge` 在 `BUNDLED_SKILL_RANK` 注册一个不可变的 `bundled` 候选项,并通过 `resourceBase` 公开其随包资产目录。交付的 CLI(命令行界面)将该插件声明为禁用,因此启用其组合配置行即为显式选择加入。 Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整,但不会在直接加载时隐藏可读候选项;项目作用域 watcher 使用按配置设限的 LRU。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2377ca0d69..bdf52f0bc5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:191`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/skill/skill-badge/src/index.ts b/packages/skill/skill-badge/src/index.ts index 27cfd29354..9cff2070fb 100644 --- a/packages/skill/skill-badge/src/index.ts +++ b/packages/skill/skill-badge/src/index.ts @@ -7,14 +7,14 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' -import type { - SkillCandidate, - SkillDefinition, - SkillProvider, +import { + BUNDLED_SKILL_RANK, + type SkillCandidate, + type SkillDefinition, + type SkillProvider, } from '@deepseek-ai/dsh-skill' const PROVIDER_NAME = 'dsh-badge' -const BUNDLED_RANK = 600 const SKILL_BODY_URL = new URL('../assets/dsh-badge.md', import.meta.url) const RESOURCE_BASE = { kind: 'directory', @@ -29,7 +29,7 @@ const CANDIDATE: SkillCandidate = { provider: PROVIDER_NAME, source: 'bundled', resourceBase: RESOURCE_BASE, - rank: BUNDLED_RANK, + rank: BUNDLED_SKILL_RANK, locator: SKILL_BODY_URL, } diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index d6df41237e..71ed3be21d 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -21,6 +21,7 @@ import { parse as parseYaml } from 'yaml' import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { + BUNDLED_SKILL_RANK, isSkillName, type SkillCandidate, type SkillDefinition, @@ -40,7 +41,6 @@ const USER_AGENTS_RANK = 500 const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200 const DEFAULT_WATCH_POLL_INTERVAL_MS = 100 const DEFAULT_WATCH_MAX_PROJECTS = 128 -const BUNDLED_RANK = 600 export const name = 'skill-local' export const inject = ['skills'] @@ -256,7 +256,7 @@ export class LocalSkillProvider implements SkillProvider { ) } if (this.bundledSkillDir !== undefined) { - roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_RANK, trustedHost: true }) + roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_SKILL_RANK, trustedHost: true }) } return roots } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 32f7112542..139b72bc8a 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -19,6 +19,9 @@ const MAX_COLLECT_ATTEMPTS = 2 const RUNTIME_PROVIDER = 'runtime' const RUNTIME_RANK = 250 +/** Standard precedence rank for packaged skill providers and local bundled roots. */ +export const BUNDLED_SKILL_RANK = 600 + /** * Return whether a string is a valid kebab-case skill name. * @param name - candidate skill name to validate. From a8a12ffc232655d3c300d90eb0c7bd45695e6591 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:04:41 +0800 Subject: [PATCH 012/100] cleanup: replace FIXMEs with tracked issues --- ...21-mandatory-app-attribution-headers.i18n.yaml | 4 ++-- ...026-06-21-mandatory-app-attribution-headers.md | 4 ++-- ...-06-21-mandatory-app-attribution-headers.zh.md | 4 ++-- .../2026-07-02-tool-render-intent-union.i18n.yaml | 4 ++-- .../2026-07-02-tool-render-intent-union.md | 2 +- .../2026-07-02-tool-render-intent-union.zh.md | 2 +- ...ariables-and-tool-guidance-ownership.i18n.yaml | 4 ++-- ...rompt-variables-and-tool-guidance-ownership.md | 2 +- ...pt-variables-and-tool-guidance-ownership.zh.md | 2 +- .../2026-07-05-reconstructable-requests.i18n.yaml | 4 ++-- .../2026-07-05-reconstructable-requests.md | 1 - .../2026-07-05-reconstructable-requests.zh.md | 1 - ...026-06-18-compaction-capability-seam.i18n.yaml | 4 ++-- .../2026-06-18-compaction-capability-seam.md | 2 +- .../2026-06-18-compaction-capability-seam.zh.md | 2 +- .../2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 4 ++-- .../feature/2026-08-02-pwsh-tool-bash-parity.md | 2 +- .../2026-08-02-pwsh-tool-bash-parity.zh.md | 2 +- ...1-installer-adopts-existing-checkout.i18n.yaml | 4 ++-- ...26-07-31-installer-adopts-existing-checkout.md | 2 +- ...07-31-installer-adopts-existing-checkout.zh.md | 2 +- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 4 ++-- .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- .../testing/2026-06-19-acp-snapshot-tests.zh.md | 2 +- .github/workflows/ci.yml | 3 ++- docs/core-data-structures/core.i18n.yaml | 4 ++-- docs/core-data-structures/core.md | 2 -- docs/core-data-structures/core.zh.md | 2 -- docs/glossary.i18n.yaml | 4 ++-- docs/glossary.md | 2 -- docs/glossary.zh.md | 2 -- examples/acp-agent/tests/acp.snapshot.ts | 4 ++-- examples/headless-agent/tests/compaction.e2e.ts | 4 ++-- packages/client/runtime/src/client/slots.ts | 15 +++++---------- packages/cordis/tool-cordis/README.i18n.yaml | 4 ++-- packages/cordis/tool-cordis/README.md | 2 +- packages/cordis/tool-cordis/README.zh.md | 2 +- packages/cordis/tool-cordis/src/guard.ts | 1 - packages/hooks/hooks-claude/tests/bridge.spec.ts | 4 ++-- packages/llm/llm/README.i18n.yaml | 4 ++-- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/attribution.ts | 4 ++-- packages/sdk/telemetry/README.i18n.yaml | 4 ++-- packages/sdk/telemetry/README.md | 4 ++-- packages/sdk/telemetry/README.zh.md | 4 ++-- packages/sdk/telemetry/src/reporter.ts | 9 ++++----- scripts/install.sh | 2 -- 48 files changed, 69 insertions(+), 87 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 56c9e53319..946d5a6117 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: a8ffe91c431cdc7907626bbc3eaf8096035777de -2026-06-21-mandatory-app-attribution-headers.zh.md: ac4affce583d5d81253f320ff022f670d4d66cc8 +2026-06-21-mandatory-app-attribution-headers.md: 28432008c354cbbb6e364746338627a26b464b0c +2026-06-21-mandatory-app-attribution-headers.zh.md: 4fb3acd72aba4bebe751f57ac0f89f776d1f1f39 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index a8ffe91c43..28432008c3 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri - product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity) - version: read from the owning package's manifest via `createRequire`, never a hand-copied constant -- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; a `FIXME` in `attribution.ts` blocks release until that repository actually exists +- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) tracks making it reachable before release The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override seam is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields. @@ -77,7 +77,7 @@ The landed contract: **Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`. -**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. The `FIXME` marker on the constant blocks a release from shipping with it unresolved (see `docs/development.md` marker semantics). +**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) owns creating it or correcting the final URL before release. **Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index ac4affce58..4fb3acd72a 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -32,7 +32,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 -- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 跟踪在发布前使其可访问 默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 @@ -77,7 +77,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 **提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 -**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,不允许带着未解决的问题出门(见 `docs/development.md` 标记语义)。 +**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 负责在发布前创建该仓库或校正最终 URL。 **不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 48d3522a70..072823d0dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md -2026-07-02-tool-render-intent-union.md: d82141f519bff66df000f1316093aacd38b8e42b -2026-07-02-tool-render-intent-union.zh.md: e145908e019e71765a475b0ca9d22414e9b42d23 +2026-07-02-tool-render-intent-union.md: 67607b2848305439513503d7e03ad5e2a2e4020a +2026-07-02-tool-render-intent-union.zh.md: 9cbab75ce87f6d313ca4b6ac8dda043733361f0d diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md index d82141f519..67607b2848 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -14,7 +14,7 @@ A tool declares how its calls render in a UI (an editor's tool-call card) throug - Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense. - There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff. -The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." An earlier rejected collapse-tool-owned-presentation proposal deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers. +An earlier rejected collapse-tool-owned-presentation proposal deferred rich rendering until it could "return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers. ## Decision diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index e145908e01..9cbab75ce8 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -14,7 +14,7 @@ Status: implemented - 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。 - 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。 -`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」一个早先被否决的折叠工具自有呈现提案明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。 +一个早先被否决的折叠工具自有呈现提案把富渲染推迟到它能够「在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归」之时。该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 8a2190b500..26354042d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 94f5fa409e7b539b48750d12576c7a342a30c9ba -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 341b3a89f423c9cc7d2fc56f1ea25a1985680d0d +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: a3b5021daf323971308760bde4f97651db8edbda +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 490d41302ea4f4e14e11e301acbf47170f95cace diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 94f5fa409e..a3b5021daf 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -10,7 +10,7 @@ The assembled system prompt had four defects, all of one family: facts the harne **The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all. -**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the old terminal welcome banner hand-enumerated the tool set too. +**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand, and the old terminal welcome banner hand-enumerated the tool set too. **The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 341b3a89f4..490d41302e 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -10,7 +10,7 @@ Status: implemented **模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何提示词文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。 -**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,旧终端欢迎横幅也手动枚举了工具集。 +**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona,旧终端欢迎横幅也手动枚举了工具集。 **Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are a coding agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index d326b413bb..2926746ee1 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: 2f559a3052b9fb84f788975a64799e4f020b0d3e -2026-07-05-reconstructable-requests.zh.md: 26abdc024a166856e51ebf09f086c7868fc8236d +2026-07-05-reconstructable-requests.md: ebca9b99cad791159302da9c2bbce9f4df147aab +2026-07-05-reconstructable-requests.zh.md: 91ef3fd04502f2c2f092da60a8bd909cf7fa25df diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 2f559a3052..ebca9b99ca 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -53,4 +53,3 @@ Like MiniCode, the conversation advances append-only and resets only when model- - Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. - Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. - Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. -- FIXME(call-config-shape): revisit `LlmCallConfig`'s exact field set — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit there out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 26abdc024a..91ef3fd045 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -53,4 +53,3 @@ Status: implemented - 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 - 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 - 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 -- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(推理(reasoning)选项、额外 body 参数)应归属何处。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index c981d84400..f071577bdd 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: 26e6e2468c7bea661d85c8fb994adf8b109105ee -2026-06-18-compaction-capability-seam.zh.md: 8f9cd1f6bc31e648a5b923e816cad75ebc1a0bd8 +2026-06-18-compaction-capability-seam.md: efb37482270a7952f6af6596f9afd12f17048bcc +2026-06-18-compaction-capability-seam.zh.md: 214832923c4e24835e7b25a5bbf2b1bcd62dff42 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 26e6e2468c..efb3748227 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -131,4 +131,4 @@ The lifecycle boundary makes crash state unambiguous: - **Loop:** Tests pin pre-step after the preceding `step/end` and before the next `step/start`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. - **Manual:** Maintenance serialization, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. -- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. +- **Snapshot gap:** The summarization call is session-associated and logs `compact/summary`, but ordinary transcript replay does not derive its auxiliary response. [#1971](https://github.com/deepseek-harness/deepseek-harness/issues/1971) tracks a keyless assembled scenario with an explicit replay override. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 8f9cd1f6bc..214832923c 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -131,4 +131,4 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab - **循环测试:** 测试固定 pre-step 发生在前一个 `step/end` 之后、下一个 `step/start` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 - **手动测试:** 无需模型密钥即可固定 maintenance 串行化、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 -- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。 +- **快照缺口:** 摘要调用与会话关联并记录 `compact/summary`,但普通 transcript(文本记录)回放不会派生其辅助响应。[#1971](https://github.com/deepseek-harness/deepseek-harness/issues/1971) 跟踪一个带显式回放 override 的无密钥组装场景。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index eea7ced3d2..40e24e9ac2 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: 945d2d5243162fe8e7fb3f76cbc3bcf0b5c2fdee -2026-08-02-pwsh-tool-bash-parity.zh.md: f537e313a0c895927c6e2319b11b98619a70d461 +2026-08-02-pwsh-tool-bash-parity.md: e35a903892d5d50a0d3ca12b23daa53f26d6aade +2026-08-02-pwsh-tool-bash-parity.zh.md: d7a68f0cab5281b25c3321e7ffff61c358b21a0a diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index 945d2d5243..e35a903892 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -14,7 +14,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi - **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. - **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. -- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls, resolving the bash tool's `FIXME(bash-env-ownership)`. +- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls; shared environment ownership therefore sits outside either model-facing shell tool. - **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. - **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index f537e313a0..d7a68f0cab 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -14,7 +14,7 @@ Status: implemented - **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 - **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 -- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁,并消化了 bash 工具的 `FIXME(bash-env-ownership)`。 +- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁;因此,共享环境的所有权不属于任何一个面向模型的 shell 工具。 - **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 - **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 1a179748f9..79ea52067d 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: de3cd052f94a0d5256c7687e9a1a38ee69fd2caf -2026-07-31-installer-adopts-existing-checkout.zh.md: 2e8be804b4af6151e77e36f8b109616aab3a18e9 +2026-07-31-installer-adopts-existing-checkout.md: ff02fe837f2ad4deb3fb852f610f3cd3ff9a23d7 +2026-07-31-installer-adopts-existing-checkout.zh.md: 28816c80764acc0d4a2fcd13b3b8a38807021fd6 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index de3cd052f9..ff02fe837f 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -46,6 +46,6 @@ A container adopting an outside clone is also no longer self-contained: deleting ## Testing -`scripts/install.sh` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing. +`scripts/install.sh` now has a real-shell PTY regression suite in `apps/cli/tests/install-script.spec.ts`, covering adoption and curl-style paths with stubbed dependencies. The installer's longer-term deletion in favor of pnpm/npx is tracked in [#1890](https://github.com/deepseek-harness/deepseek-harness/issues/1890). Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting the built layout, which is the regression that caught the unresolved-`REPO_ROOT` defect. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 2e8be804b4..28816c8076 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -46,6 +46,6 @@ Status: implemented ## Testing -`scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 +`scripts/install.sh` 现有一套位于 `apps/cli/tests/install-script.spec.ts` 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。[#1890](https://github.com/deepseek-harness/deepseek-harness/issues/1890) 跟踪安装器的长期删除工作,届时将改用 pnpm/npx。 验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index fe97a7b717..b7d396e007 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: e118ada58230fe31fbb2a6bffb83e5612757ab1f -2026-06-19-acp-snapshot-tests.zh.md: e292dbf3bf3c4c5198dc77d122bb6b8c36014ebf +2026-06-19-acp-snapshot-tests.md: 39d3b7a3f4699ea96262f43c63a7d60574ba064f +2026-06-19-acp-snapshot-tests.zh.md: 7dd3a3fa83682c35945314c7cd9531ca72bbb1fb diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index e118ada582..39d3b7a3f4 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -80,6 +80,6 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log ## Consequences -The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it). +The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here, while [#1970](https://github.com/deepseek-harness/deepseek-harness/issues/1970) tracks moving it to a transport-neutral headless suite without losing coverage. This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP. diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index e292dbf3bf..7dd3a3fa83 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -80,6 +80,6 @@ Status: implemented ## 后果 -该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。 +该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,而 [#1970](https://github.com/deepseek-harness/deepseek-harness/issues/1970) 跟踪在不损失覆盖的情况下将其迁移到传输无关的 headless 套件。 本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用回放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 821948f873..66b3a5399e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,8 @@ env: jobs: - # FIXME: Re-enable the three hosted serial reference jobs before cutting a release. + # https://github.com/deepseek-harness/deepseek-harness/issues/1967 tracks + # restoring the three hosted serial reference jobs before release. # The self-hosted standby remains active on every master push. # Three enterprise jobs isolate coverage, static analysis, and the diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 461d1ef4fc..e79d5dac2f 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: f7cf288715a3aec2f7037f12fc983e3172a77cef -core.zh.md: c17fd1335503c95e7f7f6f96cc286f567a8384e6 +core.md: dbd584f10b3daf873bc14210472efb6cd315717e +core.zh.md: 1fe1616a0c96abb4e8b91417cc4eae292416e42a diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index f7cf288715..dbd584f10b 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -530,8 +530,6 @@ The loop builds each request from logged state. `EpochHeader` records call confi On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. -FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). - ```ts type-equiv /** * Provider, model, reasoning effort, and sampling scalars of one conversation's diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index c17fd13355..1fe1616a0c 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -536,8 +536,6 @@ interface ToolSchema { 在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 -FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 - ```ts type-equiv /** * Provider, model, reasoning effort, and sampling scalars of one conversation's diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index 5c6f7d4630..2dfe08c2fd 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/glossary.md -glossary.md: 0270a2d0dba558483e8e458a932a27b0151f2c93 -glossary.zh.md: c3584731cc08b23cf7f47c09620a77b7bff65689 +glossary.md: 16409517bff623a80d6e1e00888d95f63b42f780 +glossary.zh.md: fe3138ac81f2681d7fc69cc2aede96edeb7ec176 diff --git a/docs/glossary.md b/docs/glossary.md index 0270a2d0db..16409517bf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -4,8 +4,6 @@ English | [中文](glossary.zh.md) Domain vocabulary for the DeepSeek Harness SDK uses one canonical term per concept. Terms link to their entries with standard Markdown anchors; implementation detail stays in package READMEs and Agent Notes. -FIXME(glossary-completeness): Expand this glossary before the first release so it covers the SDK's other core and capability subsystems, not only agent scope. - ## agent-scope - **scope** — the unit of per-agent registration: a contribution (tool, prompt section, variable, restriction, listener) is either *global* (visible to every agent) or *scoped* (owned by exactly one [scope key](#scope-key)). Two levels, flat: scoped registrations do not inherit down to subagents; subtree behavior is expressed with [lineage](#lineage) data, never scope structure. diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index c3584731cc..fe3138ac81 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -4,8 +4,6 @@ DeepSeek Harness SDK 的领域词汇为每个概念规定一个规范术语。各术语通过标准 Markdown 锚点链接到相应条目;实现细节留在各包的 README 与 Agent Note 中。 -FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 SDK 的其他核心与能力子系统,而非仅限于 agent scope。 - ## agent-scope - **scope**:按 agent(智能体)划分的注册单位。一项贡献(工具、提示词片段、变量、限制、监听器)要么是*全局的*(对所有 agent 可见),要么是*带作用域的*(归属于恰好一个 [scope key](#scope-key))。只有两层,采用扁平结构:带作用域的注册不会向下继承给 subagent;子树行为通过 [lineage](#lineage) 数据表达,从不通过 scope 结构。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index bf4f7ac972..b2fd25eb4e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -90,8 +90,8 @@ async function prepareFsSearchWorkspace(cwd: string): Promise { } } -// FIXME: Migrate backend-oriented scenarios to the headless stream-json suite; -// this ACP suite should eventually retain only automation-protocol contracts. +// https://github.com/deepseek-harness/deepseek-harness/issues/1970 tracks moving +// backend/product scenarios to headless while retaining ACP protocol contracts here. function fixtureRecords(name: string): unknown[] { return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8') diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index 07f239a73e..fdae11d2c7 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -10,9 +10,9 @@ import { SessionId } from '@deepseek-ai/dsh-session' /** * Key-gated smoke for mid-session compaction. It verifies the compact event * pair, replacement of older surface nodes, and a final answer after compaction. + * A keyless assembled snapshot with an explicit summarization replay override + * is tracked in https://github.com/deepseek-harness/deepseek-harness/issues/1971. */ -// FIXME(compaction-snapshot): this is the only full compaction coverage because -// replay cannot serve the summarizer's unlogged model call. let workdir: string | undefined let ctx: Context | undefined diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 3698f62aab..9da8755b2d 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -19,7 +19,7 @@ import type { Context } from 'cordis' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' import type { LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, - SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike, + SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -35,16 +35,11 @@ export interface RootOwnerProps { children?: never } /** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */ const ROOT_INSTANCE_KEY = 'root' -// FIXME(slot-parity): the engine's arbitrated persist extensions — create() -// takes the scope key (per-session localStorage suffix) and instances expose -// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike; -// these local structural faces bridge until fw-slots lifts them. +/** Canonical type-erased store handle used by the runtime lifecycle map. */ +type EngineStoreHandle = Exclude -/** Store handle face as the engine actually ships it (scope-key-aware create). */ -interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance } - -/** Engine instance face: the host-contract shape plus persisted-state cleanup. */ -interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void } +/** Canonical engine instance derived from the handle's create contract. */ +type EngineStoreInstance = ReturnType /** Store axis record: one per live handle, dropped when the last holding entry unloads. */ interface StoreAxisRecord { diff --git a/packages/cordis/tool-cordis/README.i18n.yaml b/packages/cordis/tool-cordis/README.i18n.yaml index 0a55bfc7f6..fd2b5443ef 100644 --- a/packages/cordis/tool-cordis/README.i18n.yaml +++ b/packages/cordis/tool-cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/cordis/tool-cordis/README.md -README.md: eda135d93e2912bbb4e111af40d176409b383b5b -README.zh.md: 773d4100f1be6c54f491838b65205b85ec61cdbc +README.md: 9986310160c2b56126155a4c3ef84d66018d31c2 +README.zh.md: d955306e1e5d4154f58c771704782ece44a15c99 diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index eda135d93e..9986310160 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -85,5 +85,5 @@ Mounting or unmounting a prompt or tool contribution changes later request prefi ## Known Limitations and Deferred Work - **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance). -- **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` cover every mount seen so far, and a guarded `effect` waits on a real need (`FIXME(sandbox-effect)`). +- **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths. - **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code. diff --git a/packages/cordis/tool-cordis/README.zh.md b/packages/cordis/tool-cordis/README.zh.md index 773d4100f1..d955306e1e 100644 --- a/packages/cordis/tool-cordis/README.zh.md +++ b/packages/cordis/tool-cordis/README.zh.md @@ -85,5 +85,5 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 ## 已知限制与暂缓事项 - **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。 -- **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 已覆盖目前出现的每项挂载,受保护的 `effect` 会等待真实需求(`FIXME(sandbox-effect)`)。 +- **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。 - **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。 diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 595dc462e0..22d85936f4 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -749,7 +749,6 @@ export function isPlugin(value: unknown): value is Plugin { * @param plugin - the plugin the mount code returned. * @returns an equivalent plugin whose `apply` sees the sandbox context façade. */ -// FIXME(sandbox-effect): expose guarded custom effects when a mount needs bespoke cleanup. export function guardedPlugin(plugin: Plugin): Plugin { if (typeof plugin === 'function') { const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 1da2771257..244abae423 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -225,7 +225,7 @@ describe('hooks-claude bridge — PostToolUse', () => { expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true) }) - it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => { + it('a PreToolUse permissionDecision:ask fails closed without an approval service', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) dirs.push(dir) const s = join(dir, 'ask.sh') @@ -241,7 +241,7 @@ describe('hooks-claude bridge — PostToolUse', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - // `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError. + // No approval service is mounted, so `ask` fails closed: the tool does not run and the result is isError. expect(ran).toBe(false) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 0ad49faa17..5225ba9d8c 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: d15b2c996d6d47371a3d6c5542eb5c253029dbae -README.zh.md: d965f15298f09ff9c2a953a69346c4e83136934b +README.md: 956bfa112d6fe50c35359cebdf3710064da8c130 +README.zh.md: 42f2da18089e7dcfc9acb95076ab8786c798444b diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d15b2c996d..956bfa112d 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -97,5 +97,5 @@ Pass-through; the registry preserves the assembled request prefix, while the sel - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. -- **`APP_IDENTITY.url` names a repository that does not exist yet** — `FIXME`: creating the public `deepseek-ai/deepseek-harness-sdk` repo gates the first release. +- **`APP_IDENTITY.url` names a repository that does not exist yet** — [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) tracks making the public home reachable before release. - **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would cycle; a future ids-owning package would dissolve the workaround. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index d965f15298..42f2da1808 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -97,5 +97,5 @@ - **`GenerateOptions` 采样只包含 `temperature`/`maxTokens`/`stop`**:没有 `tool_choice`、`top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见 [已删除惰性旋钮](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md))。 - **受产生方约束的变体在实际产生前不会加入**:`prefill`、每工具 `strict`、块 `cache` 提示与 `agent` 消息源变体因没有产生方而被剪除(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。 - **`BlockAssembler` 只处理核心块类型**:如果插件添加块类型的流从未由 `block-end` 关闭,`blocks()` 会抛出异常。 -- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:`FIXME`:创建公开 `deepseek-ai/deepseek-harness-sdk` 仓库是首次发布的前置条件。 +- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 跟踪在首次发布前让该公开主页可访问。 - **`GenerateOptions.sessionId` 是本地声明的品牌类型**:导入 dsh-session 的 `SessionId` 会产生循环;未来拥有 id 的包可以消除该权宜之计。 diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index cdaea4b96b..b9375b6ef9 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -40,8 +40,8 @@ export interface AppIdentity { export const APP_IDENTITY: AppIdentity = { product: 'deepseek-harness', version, - // FIXME: create the public deepseek-ai/deepseek-harness-sdk repository this - // URL promises before the first release ships attribution pointing at it. + // The public-home release blocker is tracked in + // https://github.com/deepseek-harness/deepseek-harness/issues/1972. url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', } diff --git a/packages/sdk/telemetry/README.i18n.yaml b/packages/sdk/telemetry/README.i18n.yaml index 9baaa2ebff..987dc8197c 100644 --- a/packages/sdk/telemetry/README.i18n.yaml +++ b/packages/sdk/telemetry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/telemetry/README.md -README.md: 1d33915f36e0af10eedac5f9ab34f2534268a327 -README.zh.md: af54cc2c78cb360d4305eeaf584c8330a0b7efa5 +README.md: c9f66a2415c91b75105b0ed025470da234b2523d +README.zh.md: bfb154e4c7c017292b5479e50ff376cbb9470682 diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index 1d33915f36..c9f66a2415 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -14,7 +14,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. -The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. +The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) tracks deploying the service and replacing its fail-safe `.invalid` placeholder before release. ## Model Experience @@ -26,5 +26,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set. +- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the service tracked in [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) is ready. - **Redaction is heuristic** — a conservative backstop, not a guarantee; secrets belong in `.env`, which is never read or reported. diff --git a/packages/sdk/telemetry/README.zh.md b/packages/sdk/telemetry/README.zh.md index af54cc2c78..bfb154e4c7 100644 --- a/packages/sdk/telemetry/README.zh.md +++ b/packages/sdk/telemetry/README.zh.md @@ -14,7 +14,7 @@ Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemetry 就是禁用该配置项。telemetry 默认上报,只有已经存在的 telemetry 配置项被显式设为 `disabled` 时才关闭:缺少 `cordis.yml`(首次 `create`)、配置项已启用,或 `cordis.yml` 中没有 telemetry 配置项时都会上报。`DO_NOT_TRACK`/CI 始终拒绝。无配置与缺少配置项的默认值可以通过 `ConsentResolver` 配置。 -收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);发布前必须将其 `.invalid` 占位值替换为真实端点。 +收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);[#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) 跟踪服务部署,以及发布前将作为安全兜底的 `.invalid` 占位值替换为真实端点。 ## 模型体验 @@ -26,5 +26,5 @@ Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemet ## 已知限制与暂缓事项 -- **占位端点**:`DSH_TELEMETRY_ENDPOINT` 指向 `.invalid`,直到设置真实端点。 +- **占位端点**:`DSH_TELEMETRY_ENDPOINT` 指向 `.invalid`,直至 [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) 跟踪的服务就绪。 - **脱敏依赖启发式规则**:这只是保守后备,不是保证;密钥应存放于 `.env`,而该文件绝不会被读取或上报。 diff --git a/packages/sdk/telemetry/src/reporter.ts b/packages/sdk/telemetry/src/reporter.ts index d41c1db9b7..3ad7b4b62e 100644 --- a/packages/sdk/telemetry/src/reporter.ts +++ b/packages/sdk/telemetry/src/reporter.ts @@ -16,11 +16,10 @@ import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts' import { SecretRedactor } from './secret-redactor.ts' /** - * Placeholder collection endpoint. This is a fixed protocol constant, not a - * deployment tunable. - * - * FIXME(ccyu): replace with the real telemetry endpoint before release. The - * `.invalid` TLD guarantees delivery fails harmlessly until then. + * Fail-safe placeholder collection endpoint. The `.invalid` TLD guarantees + * delivery fails harmlessly until the service tracked in + * https://github.com/deepseek-harness/deepseek-harness/issues/1973 is ready. + * This is a fixed protocol constant, not a deployment tunable. */ export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk' diff --git a/scripts/install.sh b/scripts/install.sh index 5c9d892f73..59184d91ab 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -47,8 +47,6 @@ # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) # DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh) -# FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript -# entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu DSH_REF=${DSH_REF:-master} From 62c308f4157d1b30cb5be9ad56e70c721c016e10 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:40:06 +0800 Subject: [PATCH 013/100] feat(skill): share renderSkillContent and declare the skill-invocation message source The model-facing rendering moves from dsh-tool-skill to the dsh-skill seam so the skill tool result and the upcoming user-explicit invocation injection share one canonical shape. The seam also declares the skill-invocation MessageSource kind that injection will stamp on its user-role messages. --- packages/skill/skill/package.json | 2 + packages/skill/skill/src/index.ts | 91 ++++++++++++++++++++++++ packages/skill/skill/tests/skill.spec.ts | 63 ++++++++++++++++ packages/skill/skill/tsconfig.json | 3 + packages/skill/tool-skill/src/index.ts | 59 +-------------- 5 files changed, 162 insertions(+), 56 deletions(-) diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 73469b89a7..f77f56f6d1 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -26,6 +26,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -33,6 +34,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 32f7112542..f44386d51c 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -10,6 +10,7 @@ */ import { Context, Service } from 'cordis' +import { assertNever } from '@deepseek-ai/dsh-llm' import z from 'schemastery' import type Schema from 'schemastery' @@ -119,6 +120,96 @@ export function isUserInvocable(skill: Pick): boolea return skill.invocation.userInvocable } +/** + * Durable message source for a user-explicit skill invocation: the host + * injects the rendered skill as a user-role message carrying this source, so + * transcript consumers present the invocation from metadata instead of + * re-parsing the model-facing text. + */ +export interface SkillInvocationSource { + readonly kind: 'skill-invocation' + /** Invoked skill name, validated user-invocable at the injecting boundary. */ + readonly name: string + /** Trailing free text the user submitted after the skill token, when present. */ + readonly args?: string +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + /** A user-explicit skill invocation injected by the host. */ + 'skill-invocation': SkillInvocationSource + } +} + +/** + * Render one loaded skill for the model. The output is shared verbatim by the + * `skill` tool result and the user-explicit invocation injection, so the model + * sees one canonical `` shape on both paths. The name rides an + * escaped attribute; the body is embedded verbatim (skills are trusted local + * content, and user-supplied invocation text stays outside this wrapper). + * @param skill - name, provider, optional resource base, and body to render. + * @returns the complete model-facing `` block. + */ +export function renderSkillContent(skill: Pick): string { + const resourceHint = renderResourceHint(skill) + return [ + ``, + '', + ...resourceHint, + '', + '', + '', + skill.content, + '', + '', + ].join('\n') +} + +function renderResourceHint(skill: Pick): string[] { + const base = skill.resourceBase + if (base === undefined) { + return [ + `Resources for this skill are managed by provider "${escapeText(skill.provider)}".`, + 'Load referenced resources only as needed.', + ] + } + switch (base.kind) { + case 'directory': + return [ + `Base directory for this skill: ${escapeText(base.path)}`, + 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', + ] + case 'url': + return [ + `Base URL for this skill: ${escapeText(base.url)}`, + 'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.', + ] + case 'opaque': + return [ + `Resources for this skill: ${escapeText(base.description)}`, + 'Load referenced resources only as needed.', + ] + /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */ + default: + return assertNever(base, 'SkillResourceBase.kind') + /* v8 ignore stop */ + } +} + +function escapeAttr(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') +} + +/** + * Escape model-facing prose embedded inside skill markup so provider-supplied + * text cannot open or close framing tags. + * @param value - raw prose to embed. + * @returns the escaped text. + */ +export function escapeText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') +} + /** One catalog observation plus whether discovery completed within a stable catalog revision. */ export interface SkillCatalogSnapshot { /** Sorted invocation-neutral summaries collected in this observation. */ diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 39ef384f3c..d48263cfe0 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import SkillService, { isModelInvocable, isUserInvocable, + renderSkillContent, type SkillCandidate, type SkillDefinition, type SkillInvocationPolicy, @@ -1013,3 +1014,65 @@ describe('SkillService registry', () => { expect(await ctx.skills.get('same-skill')).toBeUndefined() }) }) + +describe('renderSkillContent', () => { + it('renders a directory-based skill with the shared wrapper', () => { + const text = renderSkillContent({ + name: 'demo-skill', + provider: 'memory', + resourceBase: { kind: 'directory', path: '/tmp/demo' }, + content: 'Do the thing.', + }) + expect(text).toBe([ + '', + '', + 'Base directory for this skill: /tmp/demo', + 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', + '', + '', + '', + 'Do the thing.', + '', + '', + ].join('\n')) + }) + + it('renders url and opaque resource hints', () => { + const url = renderSkillContent({ + name: 'url-skill', + provider: 'memory', + resourceBase: { kind: 'url', url: 'https://example.test/base/' }, + content: 'Body.', + }) + expect(url).toContain('Base URL for this skill: https://example.test/base/') + expect(url).toContain('Resolve relative URLs mentioned by this skill against the base URL before using them.') + + const opaque = renderSkillContent({ + name: 'opaque-skill', + provider: 'memory', + resourceBase: { kind: 'opaque', description: 'archive ' }, + content: 'Body.', + }) + expect(opaque).toContain('Resources for this skill: archive <bundle>') + }) + + it('falls back to the provider hint without a resource base', () => { + const text = renderSkillContent({ + name: 'provider-skill', + provider: 'remote ', + content: 'Body.', + }) + expect(text).toContain('Resources for this skill are managed by provider "remote <hub>".') + }) + + it('escapes hostile attribute names and keeps the body verbatim', () => { + const text = renderSkillContent({ + name: 'x"& and as-is.', + }) + expect(text).toContain('') + expect(text).toContain('Keep and as-is.') + }) +}) diff --git a/packages/skill/skill/tsconfig.json b/packages/skill/skill/tsconfig.json index e882ed2d72..82e62d7c91 100644 --- a/packages/skill/skill/tsconfig.json +++ b/packages/skill/skill/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../llm/llm" + }, { "path": "../../support/invariants" } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index ddc45d18e9..19e154143d 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -9,12 +9,13 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' -import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' import { + escapeText, isModelInvocable, isSkillName, - type SkillDefinition, + renderSkillContent, type SkillSummary, } from '@deepseek-ai/dsh-skill' @@ -203,52 +204,6 @@ export function apply(ctx: Context, config: Config = {}): void { }) } -function renderSkillContent(skill: Pick): string { - const resourceHint = renderResourceHint(skill) - return [ - ``, - '', - ...resourceHint, - '', - '', - '', - skill.content, - '', - '', - ].join('\n') -} - -function renderResourceHint(skill: Pick): string[] { - const base = skill.resourceBase - if (base === undefined) { - return [ - `Resources for this skill are managed by provider "${escapeText(skill.provider)}".`, - 'Load referenced resources only as needed.', - ] - } - switch (base.kind) { - case 'directory': - return [ - `Base directory for this skill: ${escapeText(base.path)}`, - 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', - ] - case 'url': - return [ - `Base URL for this skill: ${escapeText(base.url)}`, - 'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.', - ] - case 'opaque': - return [ - `Resources for this skill: ${escapeText(base.description)}`, - 'Load referenced resources only as needed.', - ] - /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */ - default: - return assertNever(base, 'SkillResourceBase.kind') - /* v8 ignore stop */ - } -} - function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage { return createUserMessage({ content: [{ @@ -393,11 +348,3 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void { throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`) } } - -function escapeAttr(value: string): string { - return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') -} - -function escapeText(value: string): string { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') -} From 85422f44dc512ee5365f513b0aa5f44e11c62ddf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:51:24 +0800 Subject: [PATCH 014/100] feat(host): user-invocable skill listing and skill.invoke injection RPC skill.list now serves every user-invocable skill and carries modelInvocable so menus can mark user-only entries; the old model-and-user intersection hid disable-model-invocation skills from their only legitimate entry point (issue #1470). skill.invoke enforces user-invocation policy at the host boundary, renders the canonical body, and injects it as a user-role message carrying the skill-invocation source before starting a turn. The connection fixture mirrors both faces for client tests. --- .../client/connection/src/client/fixture.ts | 21 +++- packages/host/apiproxy/src/api-proxy.ts | 58 ++++++++- packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/api/rpc.schema.ts | 2 + packages/host/apiproxy/src/api/rpc.ts | 4 + .../host/apiproxy/src/api/skills.schema.ts | 13 ++ packages/host/apiproxy/src/api/skills.ts | 18 ++- packages/host/apiproxy/src/fetch/client.ts | 5 +- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 116 +++++++++++++++++- .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 9 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 25 +++- 13 files changed, 261 insertions(+), 16 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 776d21fd46..75653d43e3 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2449,10 +2449,28 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (missing !== undefined) return missing return ok(request, { skills: [ - { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' }, + { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true }, + { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false }, ], }) }, + invoke: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const { sessionId, name, text: args } = request.payload + const body = `\n\nBase directory for this skill: /fixture/skills/${name}\n\n\n\nFixture ${name} instructions.\n\n` + // Mirror the host: injection is a user-role message carrying the + // skill-invocation source, immediately visible in the transcript. + // The client program cannot see the host-side MessageSourceMap merge + // (sources are opaque wire JSON to the UI), so the fixture stamps the + // durable shape through the same assertion the projections read back. + const source = { kind: 'skill-invocation', name, ...args === undefined ? {} : { args } } as unknown as MessageSource + append(sessionId, { + type: 'user/message', surfaceOp: 'append', + data: userMessage(text(args === undefined ? body : `${body}\n\n${args}`), source), + }) + return ok(request, { accepted: true as const }) + }, }, goals: { // Compatibility face only: old API Proxy payloads and acknowledgements @@ -2761,6 +2779,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) + case 'skill.invoke': return this.api.skills.invoke(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e4b715a0c4..6384a4d408 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -18,6 +18,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' +import { isSkillName, isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill' +import type { SkillInvocationSource } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, @@ -2359,19 +2361,71 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) } try { - const skills = (await skillRegistry.list({ cwd })) - .filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable) + const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable) return ok(request, { skills: skills.map(skill => ({ name: skill.name, description: skill.description, ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }, + modelInvocable: skill.invocation.modelInvocable, })), }) } catch (error: unknown) { return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} }) } }, + + async invoke(request) { + const { sessionId, name, text } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const agent = found.agent + // Same turn-start refusal boundary as sessions.prompt: injection + // starts a turn, so a route no adapter serves is refused while the + // composer still shows the draft. + const target = targetFor(agent).current + if (!routeServed(target.provider)) { + return err(request, { + code: 'model-unavailable', + message: `no adapter serves provider "${target.provider}"; select a model for this session`, + details: { provider: target.provider, model: target.model }, + }) + } + const skillRegistry = ctx.get('skills') + if (skillRegistry === undefined) { + return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) + } + const lookup = { cwd: agent.session.header.cwd } + // isSkillName guards the registry contract; an ill-formed name is + // indistinguishable from an absent one for the caller. + const summary = isSkillName(name) + ? (await skillRegistry.list(lookup)).find(skill => skill.name === name) + : undefined + if (summary === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + // The operation boundary owns user-invocation policy: client menus + // filtering their candidates is an affordance, not enforcement. + if (!isUserInvocable(summary)) { + return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) + } + const skill = await skillRegistry.get(name, lookup) + if (skill === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + const body = renderSkillContent(skill) + const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } } + try { + const message: UserMessage = createUserMessage({ + content: [{ type: 'text', text: text === undefined ? body : `${body}\n\n${text}` }], + source, + }) + agent.followup(message) + } catch (error: unknown) { + return err(request, { code: 'agent-busy', message: 'skill invocation rejected', details: { reason: String(error) } }) + } + return ok(request, { accepted: true as const }) + }, }, settings: { diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 9a8750c722..b001d54625 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -50,6 +50,7 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] + 'skill.invoke': SkillsApi['invoke'] 'goal.create': GoalsApi['create'] 'goal.edit': GoalsApi['edit'] 'goal.pause': GoalsApi['pause'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 2733c6e940..dd3fe7cf57 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -51,6 +51,8 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), + z.object({ code: z.literal('skill-not-found'), message: z.string(), details: z.object({ name: z.string() }) }), + z.object({ code: z.literal('skill-not-invocable'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 54bbb5a8cc..7bf41a32e1 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -51,6 +51,10 @@ export interface RpcErrorDetailsMap { 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ 'unknown-command': {} + /** A skill invocation named no skill in the session's workspace (unknown or ill-formed name). */ + 'skill-not-found': { name: string } + /** A skill invocation named a skill whose policy forbids user invocation. */ + 'skill-not-invocable': { name: string } /** * A settings write was refused (schema validation, unknown namespace, * read-only provider, or storage failure); the message is the seam's text. diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index 3bf7ad429a..c1ee1024a3 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -14,6 +14,7 @@ export const skillEntrySchema = z.object({ name: z.string().min(1), description: z.string(), whenToUse: z.string().optional(), + modelInvocable: z.boolean(), }) satisfies z.ZodType> /** skill.list request payload. */ @@ -25,3 +26,15 @@ export const skillListRequestSchema = z.object({ export const skillListValueSchema = z.object({ skills: z.array(skillEntrySchema), }) satisfies z.ZodType>> + +/** skill.invoke request payload. */ +export const skillInvokeRequestSchema = z.object({ + sessionId: sessionIdSchema, + name: z.string().min(1), + text: z.string().optional(), +}) satisfies z.ZodType>> + +/** skill.invoke response value. */ +export const skillInvokeValueSchema = z.object({ + accepted: z.literal(true), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 33802dd4c0..2ade72efb9 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -10,16 +10,28 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' /** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */ export interface SkillEntry { - /** Kebab-case identifier referenced as `name` in prompts. */ + /** Kebab-case identifier the user references as `/name` in the composer. */ readonly name: string /** Short routing description. */ readonly description: string /** Optional extra routing guidance. */ readonly whenToUse?: string + /** False marks a user-only skill (`disable-model-invocation`): invocable here, absent from the model catalog. */ + readonly modelInvocable: boolean } -/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */ +/** Skill-domain unary methods (the map keys skill.* of RpcMethodMap). */ export interface SkillsApi { - /** Lists skills usable by the browser's user-selected model-reference path. */ + /** Lists the user-invocable skill catalog for the session's project. */ list(request: RpcRequest<{ sessionId: SessionId }>): Promise> + + /** + * Injects one user-invocable skill into the addressed agent as a user-role + * message (the canonical `` rendering, with `text` appended + * when present) and starts a turn. The host enforces user-invocation policy + * here: a model-only or unknown name is refused regardless of what a client + * menu offered. Session-backed subagents reject with `agent-busy`. + */ + invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>): + Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0f54d76dbc..574206458b 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -39,7 +39,7 @@ import { workspaceRenameValueSchema, } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' -import { skillListValueSchema } from '../api/skills.schema.ts' +import { skillInvokeValueSchema, skillListValueSchema } from '../api/skills.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -118,6 +118,7 @@ export interface IApiClient { } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> + invoke(payload: RequestPayload<'skill.invoke'>, signal?: AbortSignal): Promise>> } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -185,6 +186,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), + invoke: (payload, signal) => this.callUnary('skill.invoke', payload, signal), } readonly goals: IApiClient['goals'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d41b51ad6d..914c425e91 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -41,7 +41,7 @@ import { workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' -import { skillListRequestSchema } from '../api/skills.schema.ts' +import { skillInvokeRequestSchema, skillListRequestSchema } from '../api/skills.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -109,6 +109,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, + 'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r) => api.skills.invoke(r) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 55781a3e77..7d7062023e 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -228,7 +228,10 @@ describe('skill.list', () => { // touch (or resume through) the Agent registry. const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) const value = expectOk(await api.skills.list(request({ sessionId: session.id }))) - expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }]) + expect(value.skills).toEqual([ + { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, + { name: 'user-only', description: 'User-only', modelInvocable: false }, + ]) expect(seenCwds).toEqual(['/proj']) expect(ctx.agents.get(session.id)).toBeUndefined() }) @@ -266,6 +269,117 @@ describe('skill.list', () => { }) }) +describe('skill.invoke', () => { + /** Provider with one user-only and one model-only skill, both loadable. */ + function registerInvokeSkills(ctx: Context): void { + const summaries = [ + { + name: 'user-only', description: 'User-only', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'custom', provider: 'probe', rank: 0, locator: null, + resourceBase: { kind: 'directory', path: '/proj/.agents/skills/user-only' }, + }, + { + name: 'model-only', description: 'Model-only', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'custom', provider: 'probe', rank: 0, locator: null, + }, + ] as const + ctx.skills.registerProvider(() => ({ + name: 'probe', + list: () => Promise.resolve(summaries.map(summary => ({ ...summary }))), + get: candidate => Promise.resolve({ + ...summaries.find(summary => summary.name === candidate.name)!, + content: 'Follow the probe instructions.', + }), + })) + } + + /** Agent stub whose session carries a project cwd and whose followup records the injected message. */ + function invokableAgent(ctx: Context): { agent: Agent; followup: ReturnType } { + const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const followup = vi.fn() + const agent = { id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent + ctx.agents.register(agent) + return { agent, followup } + } + + it('injects a user-invocable skill as a user message with the invocation source', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const value = expectOk(await api.skills.invoke(request({ + sessionId: agent.id, name: 'user-only', text: 'and check the fixture', + }))) + expect(value).toEqual({ accepted: true }) + expect(followup).toHaveBeenCalledTimes(1) + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only', args: 'and check the fixture' }) + expect(message.content).toHaveLength(1) + const text = (message.content[0] as { text: string }).text + expect(text).toContain('') + expect(text).toContain('Base directory for this skill: /proj/.agents/skills/user-only') + expect(text).toContain('Follow the probe instructions.') + expect(text.endsWith('\n\nand check the fixture')).toBe(true) + }) + + it('omits args from the source and content when no text rides the invocation', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }))) + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only' }) + const text = (message.content[0] as { text: string }).text + expect(text.endsWith('')).toBe(true) + }) + + it('rejects a skill the user may not invoke', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' }))) + expect(error.code).toBe('skill-not-invocable') + expect(followup).not.toHaveBeenCalled() + }) + + it('rejects an unknown or invalid skill name', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent } = invokableAgent(ctx) + const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' }))) + expect(missing.code).toBe('skill-not-found') + const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }))) + expect(invalid.code).toBe('skill-not-found') + }) + + it('surfaces a followup refusal as agent-busy', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + followup.mockImplementation(() => { throw new Error('inbox closed') }) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }))) + expect(error.code).toBe('agent-busy') + }) + + it('fails loud with internal when the skill registry is not mounted', async () => { + const ctx = await harness({ skills: false }) + const api = createApiProxy(ctx, DEFAULTS) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup: vi.fn() } as unknown as Agent) + const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }))) + expect(error.code).toBe('internal') + expect(error.message).toContain('skill registry is absent') + }) +}) + describe('host/commands-changed frame', () => { it('broadcasts on registry change', async () => { const ctx = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ebd56ee551..0a65c817c6 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -86,7 +86,7 @@ function scriptedApi(overrides: { execute: r => ok(r, { matched: false }), ...overrides.commands, }, - skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, + skills: { list: r => ok(r, { skills: [] }), invoke: r => ok(r, { accepted: true as const }), ...overrides.skills }, goals: { create: err, edit: err, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 22e1650f5b..09cabdcc7f 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -196,7 +196,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, skills: { async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } } + return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } + }, + async invoke(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, }, goals: { @@ -381,7 +384,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' }) expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) - expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } }) + expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) + const invoked = await c.skills.invoke({ sessionId: 's' as never, name: 'commit-helper', text: 'go' }) + expect(invoked.result).toEqual({ ok: true, value: { accepted: true } }) }) it('lets command.execute finish after the 30-second default unary deadline', async () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 28f9138502..253ac92fdf 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -31,7 +31,7 @@ import { commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema, commandListRequestSchema, commandListValueSchema, } from '../src/api/commands.schema.ts' -import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' +import { skillEntrySchema, skillInvokeRequestSchema, skillInvokeValueSchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' @@ -74,6 +74,8 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') + expect(rpcErrorSchema.parse({ code: 'skill-not-found', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-found') + expect(rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-invocable') expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -81,6 +83,7 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) @@ -395,12 +398,26 @@ describe('skills domain schemas', () => { expect(() => skillListRequestSchema.parse({})).toThrow() expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([]) const value = skillListValueSchema.parse({ skills: [ - { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }, - { name: 'bare', description: 'No guidance' }, + { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, + { name: 'bare', description: 'No guidance', modelInvocable: false }, ] }) expect(value.skills[0]?.whenToUse).toBe('when committing') expect(value.skills[1]?.whenToUse).toBeUndefined() - expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow() + expect(value.skills[1]?.modelInvocable).toBe(false) + expect(() => skillEntrySchema.parse({ name: '', description: 'd', modelInvocable: true })).toThrow() + // modelInvocable is required wire data: an entry without it fails. + expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow() + }) + + it('validates the invoke request/value pair', () => { + expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only' })) + .toEqual({ sessionId: 's1', name: 'user-only' }) + expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: 'check it' }).text) + .toBe('check it') + expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: '' })).toThrow() + expect(() => skillInvokeRequestSchema.parse({ name: 'user-only' })).toThrow() + expect(skillInvokeValueSchema.parse({ accepted: true })).toEqual({ accepted: true }) + expect(() => skillInvokeValueSchema.parse({ accepted: false })).toThrow() }) }) From 0490f8bb0621cb681c9b7c219ffef1c79247db94 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:51:54 +0800 Subject: [PATCH 015/100] feat(llm-pi-ai): per-model reasoningEfforts and reasoning-dispatch compat switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model entry's reasoningEfforts dict declares its selectable thinking levels — key = offered level, value = the wire spelling dispatch sends; only off may leave the value empty (supported, send nothing). false strips reasoning from a catalog model; every level is materialized explicitly into pi-ai's thinkingLevelMap so nobody has to know pi-ai's asymmetric absent-key defaulting. compat.thinkingFormat and compat.supportsReasoningEffort become configurable on the route and per model (model > route > catalog entry > pi-ai's URL-derived guess), openai-completions only, so a private gateway speaking the DeepSeek reasoning dialect no longer depends on its URL being recognizable. Record-typed drift gates pin both enums to pi-ai's, and an unserviceable declaration is refused at the write that produced it, naming route, model, and level. --- apps/web/tests/declared-reasoning.e2e.ts | 95 +++++++ apps/web/tests/declared-reasoning.overlay.yml | 8 + .../declared-reasoning/ui.expected.md | 7 + apps/web/tsconfig.json | 1 + packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 31 ++- packages/llm/llm-pi-ai/README.zh.md | 31 ++- packages/llm/llm-pi-ai/src/catalog.ts | 239 +++++++++++++++++- packages/llm/llm-pi-ai/src/config.ts | 40 ++- packages/llm/llm-pi-ai/src/index.ts | 22 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 143 +++++++++++ packages/llm/llm-pi-ai/tests/catalog.spec.ts | 175 ++++++++++++- packages/llm/llm-pi-ai/tests/config.spec.ts | 32 ++- tsconfig.host.json | 1 + 14 files changed, 806 insertions(+), 23 deletions(-) create mode 100644 apps/web/tests/declared-reasoning.e2e.ts create mode 100644 apps/web/tests/declared-reasoning.overlay.yml create mode 100644 apps/web/tests/snapshots/declared-reasoning/ui.expected.md diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts new file mode 100644 index 0000000000..664f20dfe6 --- /dev/null +++ b/apps/web/tests/declared-reasoning.e2e.ts @@ -0,0 +1,95 @@ +// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the +// composer's effort pane — the levels a settings profile declares are exactly +// what the picker offers, and picking one records it with the default route. +// Zero model calls: declaring, describing, and switching are settings/llm +// traffic only, so there is no fixture and a stray stream would fail loud. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +/** Starts the shipped default on this scenario's declared reasoning model. */ +const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/declared-reasoning', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/declared-reasoning/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + // The whole reasoning offer is the profile: key = selectable level, value + // = the wire spelling dispatch would send (`max: ultra` renames; the + // valueless `off` means "supported, send nothing"). The route sets no + // deployment default, so the pane leads with the provider-default entry. + await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL: 'https://gateway.acme.example/v1', + models: [{ + id: 'acme-think', + name: 'Acme Think', + reasoningEfforts: { off: null, high: 'high', max: 'ultra' }, + }], + }, + }, + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers exactly the declared levels and records the picked one', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning')) + const trigger = page.getByRole('button', { name: /^选择模型/ }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + await page.getByRole('menuitem', { name: /推理等级/ }).click() + + // Declared levels, nothing else: the provider-default entry (the route + // configures no `reasoning`), then Off/High/Max — minimal, low, medium, + // and xhigh were not declared and must not be offered. + const levels = page.getByRole('menuitemradio') + await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 }) + .toEqual(['Default', 'Off', 'High', 'Max']) + const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + + // Picking a level is the same gesture that saves the default target, so + // the effort lands in the gateway's settings section beside the route. + await page.getByRole('menuitemradio', { name: 'High' }).click() + await expect.poll( + async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), + { timeout: 10_000 }, + ).toContain('reasoningEffort: high') + await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 }) + .toBe('选择模型,当前 Acme Think,推理等级 High') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/declared-reasoning.overlay.yml b/apps/web/tests/declared-reasoning.overlay.yml new file mode 100644 index 0000000000..d90452178c --- /dev/null +++ b/apps/web/tests/declared-reasoning.overlay.yml @@ -0,0 +1,8 @@ +# The fixture-less web scaffold registers no adapter, so the shipped +# deepseek-official default would be a route nothing serves. This scenario +# starts the default on its own declared reasoning model so the effort pane +# describes that model from the first open. +- id: api-gateway + config: + provider: acme-gateway + model: acme-think diff --git a/apps/web/tests/snapshots/declared-reasoning/ui.expected.md b/apps/web/tests/snapshots/declared-reasoning/ui.expected.md new file mode 100644 index 0000000000..810a6bf8b5 --- /dev/null +++ b/apps/web/tests/snapshots/declared-reasoning/ui.expected.md @@ -0,0 +1,7 @@ +- menu "模型与推理等级": + - menuitemradio "Default" [checked]: + - text: Default + - img + - menuitemradio "Off" + - menuitemradio "High" + - menuitemradio "Max" diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 528714a527..48275db0d6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -38,6 +38,7 @@ "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", "tests/default-model.e2e.ts", + "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index b57043a84d..69efba1977 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 97bd629adedda9d63fee730bc31129b0c22cc704 -README.zh.md: 71d45b590f48f4b8162ae329b58b5ff4a9eb13b1 +README.md: 894aecc720f0a7616c0127d439b41129d94ef667 +README.zh.md: 63464f80ee3036ddec3fb6828ecccc68c5524478 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 97bd629ade..894aecc720 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -42,18 +42,41 @@ Configure credentials, the model catalog, and deployment-specific transport sett apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + contextWindow: 262144 + maxTokens: 32768 + # key = selectable level, value = its wire spelling; only off may + # leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Each entry carries `declared`: whether pi-ai ships nothing under that key. It follows the installed catalog, never the settings document, because narrowing a shipped provider's models stores a profile too and that route is still one pi-ai knows — only the adapter can tell the two apart, which is why the directory answers rather than leaving a surface to infer it. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`. ## Catalog resolution -A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. Reasoning is not per-model configurable at all: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, and no listing endpoint reports a model's reasoning protocol, so reasoning rides the installed catalog entry or is absent. +A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits — but declaring any `models` list means every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. The configurable entry fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. + +### Per-model reasoning efforts + +`reasoningEfforts` declares a model's selectable thinking levels: each key is a level selectors offer, its value the spelling dispatch sends on the wire, so `high: high` passes the canonical name through while `max: ultra` renames it for a gateway with its own vocabulary. Keys come from pi-ai's level set (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); a level not declared is not offered. Omitting the field keeps the installed catalog entry's capability (a hand-declared model has none and does not reason); `false` declares a non-reasoning model, which is how a profile strips reasoning from a catalog model its gateway cannot serve; an empty declaration is refused rather than guessing between those two meanings. + +The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, the model cannot stop thinking and selectors offer no Off; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. + +### Reasoning-dispatch compat switches + +How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking: {type}` plus effort, z.ai's `thinking` object, and so on — is pi-ai's `compat.thinkingFormat`, which pi-ai guesses from the endpoint URL; a private gateway's URL says nothing, so a DeepSeek-dialect gateway would be spoken to in the OpenAI dialect with no way to correct it. `compat.thinkingFormat` and `compat.supportsReasoningEffort` are therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's URL-derived guess; setting a route-level switch shadows the catalog entry's value for every model on the route, and there is no spelling for handing a field back to the catalog short of restating its value. `thinkingFormat` accepts pi-ai's dispatchable formats except the two `chat-template` variants, which need `chatTemplateKwargs` this configuration does not expose. Both switches exist only on `openai-completions` — the other protocols carry their reasoning shape in the protocol itself — so a model-level switch elsewhere fails resolution, a route-level one skips models of other protocols, and a route with no `openai-completions` model at all is refused. The rest of pi-ai's compat surface (`supportsStore`, `maxTokensField`, …) stays auto-detected and is deliberately not configurable here. A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. @@ -71,11 +94,11 @@ Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `ap The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. -A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. +A model that carries reasoning metadata — from the installed catalog or from its entry's `reasoningEfforts` — exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. -A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. +A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 71d45b590f..63464f80ee 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -42,18 +42,41 @@ apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + contextWindow: 262144 + maxTokens: 32768 + # key = selectable level, value = its wire spelling; only off may + # leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` 字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。每个条目都带上 `declared`:pi-ai 在这个键下是否什么都没有。它跟随已安装 catalog 而非设置文档,因为收窄一个内置提供方的模型同样会存下 profile,而那条路由仍然是 pi-ai 认识的——只有适配器分得清两者,所以由目录直接给出答案,而不是留给界面去猜。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 ## Catalog 解析 -profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow` 与 `maxTokens`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。推理则完全不按模型配置:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,而且没有任何列表端点会报告模型的推理协议,因此推理沿用已安装 catalog 条目或直接缺席。 +profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑——但一旦声明了 `models` 列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。可配置的条目字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。 + +### 按模型的推理档位 + +`reasoningEfforts` 声明模型可选的思考级别:每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写,因此 `high: high` 原样透传规范名称,而 `max: ultra` 则为使用自有词汇的网关改名。键取自 pi-ai 的档位集合(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`);未声明的档位不会被提供。省略该字段会保留已安装 catalog 条目的能力(手工声明的模型没有这份能力,也不推理);`false` 声明一个不具备推理能力的模型,profile 正是以此从其网关无法服务的 catalog 模型上剥除推理;空声明会被拒绝,而不是在这两种含义之间去猜。 + +该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,模型就无法停止思考,选择器也不提供 Off;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 + +### 推理分派的 compat 开关 + +思考级别如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加上档位、z.ai 的 `thinking` 对象,诸如此类——就是 pi-ai 的 `compat.thinkingFormat`,pi-ai 会从端点 URL 猜测它;私有网关的 URL 什么也说明不了,于是说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且无从更正。因此 `compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测;设置路由级开关会为路由上的每个模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。`thinkingFormat` 接受 pi-ai 可分派的各种格式,但不含两个 `chat-template` 变体:它们需要的 `chatTemplateKwargs` 本配置并不暴露。两个开关都只存在于 `openai-completions` 上——其余协议的推理形状由协议本身承载——因此在其他协议的模型上设置模型级开关会使解析失败,路由级开关会跳过其他协议的模型,而完全没有 `openai-completions` 模型的路由则会被拒绝。pi-ai compat 面的其余部分(`supportsStore`、`maxTokensField`……)保持自动检测,特意不在此处开放配置。 条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成每请求上限。 @@ -71,11 +94,11 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 -携带推理元数据的模型会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。 +携带推理元数据的模型——来自已安装 catalog,或来自其条目的 `reasoningEfforts`——会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。 -**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 +**没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 173b84dd7d..e3c9207927 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -14,7 +14,15 @@ import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' -import type { Api, Model, ModelCost, Provider } from '@earendil-works/pi-ai' +import type { + Api, + Model, + ModelCost, + ModelThinkingLevel, + OpenAICompletionsCompat, + Provider, + ThinkingLevelMap, +} from '@earendil-works/pi-ai' /** * Pricing for a model the installed catalog does not describe. The harness @@ -30,6 +38,58 @@ const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } */ const TEXT_ONLY: Model['input'] = ['text'] +/** + * Every pi-ai thinking level, in pi-ai's canonical escalation order. The + * `Record` key type is a drift gate: a pi-ai upgrade that adds or removes a + * level fails compilation here naming the drifted key, instead of silently + * narrowing what a profile may declare. + */ +const THINKING_LEVEL_GATE: Record = { + off: true, + minimal: true, + low: true, + medium: true, + high: true, + xhigh: true, + max: true, +} + +/** Every pi-ai thinking level a profile may declare, in escalation order. */ +export const THINKING_LEVELS = Object.keys(THINKING_LEVEL_GATE) as readonly ModelThinkingLevel[] + +/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ +type PiThinkingFormat = NonNullable + +/** + * pi-ai thinking formats a profile cannot name: both drive the request through + * `chatTemplateKwargs`, which this configuration does not expose, so offering + * them would hand back a format with nothing to say. + */ +type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' + +/** One reasoning-dispatch wire format a profile may name. */ +export type PiAiThinkingFormat = Exclude + +/** + * The nameable reasoning-dispatch formats, most-reached first. The `Record` + * key type is a drift gate: a pi-ai upgrade that adds a format (0.84 added + * `baseten`) fails compilation here until the format is classified as offered + * here or withheld above, so the offer never silently lags the upstream set. + */ +const THINKING_FORMAT_GATE: Record = { + 'openai': true, + 'deepseek': true, + 'openrouter': true, + 'together': true, + 'zai': true, + 'qwen': true, + 'string-thinking': true, + 'ant-ling': true, +} + +/** Reasoning-dispatch wire formats a profile may name, most-reached first. */ +export const SUPPORTED_THINKING_FORMATS = Object.keys(THINKING_FORMAT_GATE) as readonly PiAiThinkingFormat[] + let providerIndex: Map | undefined /** @@ -71,6 +131,32 @@ export function catalogModels(provider: string): Map> { return new Map(models.map(model => [model.id, model])) } +/** + * Selectable reasoning efforts for one model: each key is a level the model + * offers (and selectors show), and its value is the wire spelling dispatch + * sends for it. `off` alone may leave its value empty — "supported, send + * nothing" — because for most providers not thinking is the parameter's + * absence; every other declared level must name a wire value. A level absent + * from the dict is not offered. + */ +export type PiAiReasoningEfforts = Partial> + +/** + * Reasoning-dispatch compatibility switches, set on the route (its models' + * default) or per model (winning over the route). Only the switches pi-ai's + * reasoning dispatch reads are offered; the rest of pi-ai's compat surface + * keeps its baseURL-derived auto-detection. pi-ai types both fields only on + * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning + * shape in the protocol itself — so resolution rejects a model-level switch + * anywhere else, while a route-level default skips past models it cannot fit. + */ +export interface PiAiCompatProfile { + /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + thinkingFormat?: PiAiThinkingFormat + /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + supportsReasoningEffort?: boolean +} + /** One configured model entry: an id plus the catalog fields it overrides. */ export interface PiAiModelProfile { /** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */ @@ -86,6 +172,16 @@ export interface PiAiModelProfile { * default on its own. */ maxTokens?: number + /** + * Selectable reasoning efforts. Absent inherits the installed catalog + * entry's capability (a hand-declared model has none and does not reason); + * `false` declares a non-reasoning model, which is how a profile strips + * reasoning from a catalog model its gateway cannot serve; a non-empty dict + * declares the offered levels and their wire spellings. + */ + reasoningEfforts?: false | PiAiReasoningEfforts + /** Reasoning-dispatch switches for this model, winning over the route's. */ + compat?: PiAiCompatProfile } /** The route-level facts model materialization reads. */ @@ -98,6 +194,8 @@ export interface RouteCatalogRequest { baseURL?: string /** Configured catalog; absent means the whole installed catalog for this route. */ models?: readonly PiAiModelProfile[] + /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */ + compat?: PiAiCompatProfile /** Context capacity for a model neither the entry nor the catalog sizes. */ defaultContextWindow: number /** Output capability for a model neither the entry nor the catalog sizes. */ @@ -123,6 +221,133 @@ function sharedCatalogApi(defaults: ReadonlyMap>): string | u return apis.size === 1 ? [...apis][0] : undefined } +/** The reasoning fields one materialized model carries. */ +interface ModelReasoning { + /** Whether the model reasons at all; `false` makes pi-ai ignore the map. */ + reasoning: boolean + /** The map dispatch reads; absent only when the installed entry's (or none) applies. */ + thinkingLevelMap?: ThinkingLevelMap +} + +/** + * Resolve one model's reasoning capability from its declared efforts. + * + * A declared dict translates to pi-ai's `thinkingLevelMap` with every level + * decided explicitly: declared levels carry their wire spelling, undeclared + * levels are pinned to `null` (unsupported). Pinning matters because pi-ai's + * own defaulting is asymmetric — an absent key means "supported" for the five + * base levels but "unsupported" for `xhigh`/`max` — and a profile author + * should not need to know that. A declared `off` with no value is the one + * exception: it stays absent from the map, which pi-ai reads as "supported, + * send nothing" — the correct dispatch where not thinking is the parameter's + * absence — while `off` with a value sends that value. + * @param provider - provider route key, for diagnostics. + * @param entry - the configured model entry. + * @param base - the installed catalog entry of the same id, when one exists. + * @returns the reasoning fields the materialized model carries. + */ +function resolveModelReasoning( + provider: string, + entry: PiAiModelProfile, + base: Model | undefined, +): ModelReasoning { + const efforts = entry.reasoningEfforts + if (efforts === undefined) { + // Reasoning rides the installed entry or is absent: a bare capability flag + // would make pi-ai advertise effort levels with no `thinkingLevelMap` to + // spell them, and no listing endpoint reports a model's reasoning + // protocol. The entry's map (when any) arrives through the `...base` + // spread in the model literal. + return { reasoning: base?.reasoning ?? false } + } + // The installed entry's map may ride along through `...base`; pi-ai never + // reads it on a non-reasoning model, so stripping it is not worth a field + // enumeration here. + if (efforts === false) return { reasoning: false } + // A YAML `reasoningEfforts:` left valueless arrives as null through the + // schema union — outside the field's declared type, hence the widening — + // while an explicit `{}` arrives as an empty dict. Both declare nothing, + // and neither is a spelling of "inherit" or "disable". + if ((efforts as unknown) === null || Object.keys(efforts).length === 0) { + invalid(provider, `model "${entry.id}" has an empty reasoningEfforts; declare the offered levels, set` + + ' false for a non-reasoning model, or omit the field to keep the installed catalog\'s capability') + } + const declared = THINKING_LEVELS.flatMap((level) => { + const wire = efforts[level] + return wire === undefined ? [] : [[level, wire] as const] + }) + for (const [level, wire] of declared) { + if (wire === null) { + if (level !== 'off') { + invalid(provider, `model "${entry.id}" reasoningEfforts.${level} needs the wire value dispatch` + + ' should send; only "off" may leave it empty') + } + } else if (wire.length === 0) { + invalid(provider, `model "${entry.id}" reasoningEfforts.${level} must not be an empty string`) + } + } + if (!declared.some(([level]) => level !== 'off')) { + invalid(provider, `model "${entry.id}" reasoningEfforts offers no level beyond "off"; declare a thinking` + + ' level, or set reasoningEfforts to false for a non-reasoning model') + } + const map: ThinkingLevelMap = {} + for (const level of THINKING_LEVELS) { + const wire = efforts[level] + if (wire === undefined) { + map[level] = null + } else if (wire !== null) { + map[level] = wire + } + } + return { reasoning: true, thinkingLevelMap: map } +} + +/** + * Resolve one model's compat block from the profile's reasoning switches. + * + * A model switch wins over the route switch; whatever neither sets keeps the + * installed entry's value, and a field no layer decides falls through to + * pi-ai's baseURL-derived detection. Only an `openai-completions` model takes + * the switches at all: a model-level switch on any other protocol fails + * resolution, while a route-level default skips past such models — the same + * posture as the route-level `reasoning` default, which also must not fail + * models it does not fit. + * @param provider - provider route key, for diagnostics. + * @param entry - the configured model entry. + * @param route - the route-level switches, when any. + * @param base - the installed catalog entry of the same id, when one exists. + * @param api - the model's resolved wire protocol. + * @returns a `compat` field to spread into the model, or nothing. + */ +function resolveModelCompat( + provider: string, + entry: PiAiModelProfile, + route: PiAiCompatProfile | undefined, + base: Model | undefined, + api: string, +): { compat: OpenAICompletionsCompat } | Record { + const thinkingFormat = entry.compat?.thinkingFormat ?? route?.thinkingFormat + const supportsReasoningEffort = entry.compat?.supportsReasoningEffort ?? route?.supportsReasoningEffort + if (thinkingFormat === undefined && supportsReasoningEffort === undefined) return {} + if (api !== 'openai-completions') { + if (entry.compat?.thinkingFormat !== undefined || entry.compat?.supportsReasoningEffort !== undefined) { + invalid(provider, `model "${entry.id}" sets compat reasoning switches, but its api is "${api}";` + + ' thinkingFormat and supportsReasoningEffort exist only on openai-completions') + } + return {} + } + // The installed entry's compat matches its own api, so on an + // openai-completions model it is the completions shape. + const inherited: OpenAICompletionsCompat | undefined = base?.compat + return { + compat: { + ...inherited, + ...thinkingFormat === undefined ? {} : { thinkingFormat }, + ...supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort }, + }, + } +} + /** One route's materialized catalog, plus the request caps its profile chose. */ export interface RouteCatalog { /** The materialized models in configuration order. */ @@ -164,6 +389,8 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { + ' must be listed in configuration') } const routeApi = sharedCatalogApi(defaults) + const routeCompatDefined = request.compat?.thinkingFormat !== undefined + || request.compat?.supportsReasoningEffort !== undefined const seen = new Set() const configuredMaxTokens = new Map() const models = entries.map((entry) => { @@ -209,15 +436,17 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { api, provider, baseUrl, - // Reasoning rides the installed entry or is absent: a bare boolean would - // make pi-ai advertise effort levels with no `thinkingLevelMap` to spell - // them, and no listing endpoint reports a model's reasoning protocol. - reasoning: base?.reasoning ?? false, input: base?.input ?? TEXT_ONLY, cost: base?.cost ?? NO_COST, contextWindow, maxTokens, + ...resolveModelReasoning(provider, entry, base), + ...resolveModelCompat(provider, entry, request.compat, base, api), } }) + if (routeCompatDefined && !models.some(model => model.api === 'openai-completions')) { + invalid(provider, 'sets compat reasoning switches, but no model on the route speaks openai-completions;' + + ' thinkingFormat and supportsReasoningEffort exist only on that protocol') + } return { models, configuredMaxTokens } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 7e8374ab9f..9d4cca089c 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -21,8 +21,8 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' -import { resolveRouteModels } from './catalog.ts' -import type { PiAiModelProfile } from './catalog.ts' +import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts' +import type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts' import { buildProvider, supportedProtocols } from './provider.ts' /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -34,7 +34,7 @@ export const DEFAULT_CONTEXT_WINDOW = 262_144 /** Output capability assumed for a model neither configuration nor the catalog sizes. */ export const DEFAULT_MAX_TOKENS = 32_768 -export type { PiAiModelProfile } from './catalog.ts' +export type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts, PiAiThinkingFormat } from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { @@ -62,6 +62,13 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Reasoning-dispatch switches for every `openai-completions` model on this + * route; each model's own `compat` overrides per field. What neither sets + * keeps the installed catalog entry's value, then pi-ai's baseURL-derived + * detection. + */ + compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor * the installed catalog sizes (default 262,144). A guess by construction, so @@ -139,11 +146,34 @@ const thinkingBudgets = z.object({ high: z.number(), }) +const compatProfile: z = z.object({ + thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + supportsReasoningEffort: z.boolean(), +}) + +/** + * Keys are the offered levels, values their wire spellings. `z.const(null)` + * keeps a valueless key (`off:`) alive through validation — only resolution + * decides which levels may leave the value empty, so the diagnostic can name + * the route and model. The assertion narrows schemastery's `Dict`, which + * types every literal key as required; dict validation is per-present-key, so + * the runtime shape is the partial record. + */ +const reasoningEfforts = z.dict( + z.union([z.string(), z.const(null)]), + z.union(THINKING_LEVELS), +) as unknown as z + const modelProfile: z = z.object({ id: z.string().required(), name: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), + // The union, not a bare dict: schemastery materializes an absent dict as + // `{}`, and absent must stay distinguishable — it means "inherit the + // installed catalog's capability", while `false` disables reasoning. + reasoningEfforts: z.union([z.const(false), reasoningEfforts]), + compat: compatProfile, }) const profile = z.object({ @@ -153,10 +183,11 @@ const profile = z.object({ api: z.union(supportedProtocols()), baseURL: z.string(), models: z.array(modelProfile), + compat: compatProfile, defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS), headers: z.dict(z.string()), - reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), + reasoning: z.union(THINKING_LEVELS), thinkingBudgets, cacheRetention: z.union(['none', 'short', 'long']), transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']), @@ -260,6 +291,7 @@ export function resolveProfiles( ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, ...source.models === undefined ? {} : { models: source.models }, + ...source.compat === undefined ? {} : { compat: source.compat }, defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS, }) diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 2f98d7ac70..ea81f66fec 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -32,11 +32,24 @@ * apiKeyEnv: ACME_GATEWAY_API_KEY * api: openai-completions * baseURL: https://gateway.acme.example/v1 + * # Reasoning dialect for a URL pi-ai cannot recognize. + * compat: + * thinkingFormat: deepseek * models: * - id: acme-large * name: Acme Large * contextWindow: 65536 * maxTokens: 4096 + * - id: acme-think + * name: Acme Think + * contextWindow: 262144 + * maxTokens: 32768 + * # key = selectable level, value = wire spelling; only off may + * # leave the value empty (supported, send nothing). + * reasoningEfforts: + * off: + * high: high + * max: ultra * ``` * * @module @deepseek-ai/dsh-llm-pi-ai @@ -55,7 +68,14 @@ import { discoverModels } from './discovery.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' -export type { PiAiModelProfile, PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +export type { + PiAiCompatProfile, + PiAiModelProfile, + PiAiProviderProfile, + PiAiReasoningEfforts, + PiAiThinkingFormat, + ResolvedPiAiProviderProfile, +} from './config.ts' export { supportedProtocols } from './provider.ts' export const name = 'llm-pi-ai' diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 0184ca05cc..2d7798ff2e 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -400,6 +400,149 @@ describe('provider profile lifecycle', () => { .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } }) }) + it('serves declared reasoning efforts to selectors and honours the profile default', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKey: 'gw-key', + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + reasoning: 'high', + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, low: 'low', high: 'high' }, + }], + }, + }, + }) + + // Declared levels reach the same seam catalog metadata does, so the + // effort picker works for a model pi-ai has never heard of. + await expect(ctx.llm.resolveModelInfo('acme-gateway', 'acme-think')).resolves.toMatchObject({ + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }) + }) + + it('sends the declared wire spelling and refuses undeclared levels before network I/O', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKey: 'gw-key', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'ultra' }, + }], + }, + }, + }) + + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('high'), + messages: [], + }) + // The declared value, not the canonical level name, goes on the wire. + expect(server.requests[0]).toMatchObject({ reasoning_effort: 'ultra' }) + + const undeclared = await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('max'), + messages: [], + }) + expect(undeclared.finish).toMatchObject({ + kind: 'error', + failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, + }) + expect(server.requests).toHaveLength(1) + }) + + it('dispatches the compat-switched dialect on a declared route', async () => { + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKey: 'gw-key', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + // Without the switch pi-ai guesses the dialect from the endpoint + // URL, and a private gateway's URL says nothing. + compat: { thinkingFormat: 'deepseek' }, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'high' }, + }], + }, + }, + }) + const prompt = (effort: string): Promise => assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId(effort), + messages: [], + }) + + await prompt('high') + expect(server.requests[0]).toMatchObject({ thinking: { type: 'enabled' }, reasoning_effort: 'high' }) + + await prompt('off') + expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } }) + expect(server.requests[1]).not.toHaveProperty('reasoning_effort') + }) + + it('holds back reasoning_effort when the endpoint cannot take it', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKey: 'gw-key', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + compat: { supportsReasoningEffort: false }, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'high' }, + }], + }, + }, + }) + + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('high'), + messages: [], + }) + expect(server.requests[0]).not.toHaveProperty('reasoning_effort') + }) + it('accepts absent credentials for pi-ai ambient authentication', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 2afbb87ec0..fbfcd653cf 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -10,8 +10,8 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' -import { createModels } from '@earendil-works/pi-ai' -import type { Api, Model, Provider } from '@earendil-works/pi-ai' +import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai' +import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-works/pi-ai' import { resolveProfiles } from '../src/config.ts' import { buildProvider, supportedProtocols } from '../src/provider.ts' import { assemble } from './assemble.ts' @@ -475,6 +475,177 @@ describe('catalog routes with per-model configuration', () => { }) }) +describe('per-model reasoning efforts', () => { + /** One hand-declared route holding exactly the given models. */ + function declared(models: LlmPiAi.PiAiModelProfile[]): Record { + return { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models } } + } + + /** The first materialized model of one route, or throw. */ + function modelOf(providers: Record, route = 'acme-gateway'): Model { + const [model] = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? [] + if (model === undefined) throw new Error(`route "${route}" resolved no models`) + return model + } + + it('declares selectable levels with their wire spellings on a hand-declared model', () => { + const model = modelOf(declared([{ + id: 'acme-think', + reasoningEfforts: { off: null, low: 'low', high: 'high', max: 'ultra' }, + }])) + + expect(model.reasoning).toBe(true) + // Undeclared levels are pinned null rather than left to pi-ai's own + // defaulting, which is asymmetric: an absent key means "supported" for the + // five base levels but "unsupported" for xhigh/max. A profile author + // should not need to know that. Declared `off` with no value stays absent + // from the map — supported, send nothing. + expect(model.thinkingLevelMap).toEqual({ + minimal: null, + medium: null, + xhigh: null, + low: 'low', + high: 'high', + max: 'ultra', + }) + expect(getSupportedThinkingLevels(model)).toEqual(['off', 'low', 'high', 'max']) + }) + + it('sends a declared off value on the wire instead of omitting the parameter', () => { + const model = modelOf(declared([{ id: 'm', reasoningEfforts: { off: 'none', high: 'high' } }])) + expect(model.thinkingLevelMap?.off).toBe('none') + expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high']) + }) + + it('offers exactly the declared keys: leaving off out makes thinking mandatory', () => { + const model = modelOf(declared([{ id: 'm', reasoningEfforts: { high: 'high' } }])) + expect(getSupportedThinkingLevels(model)).toEqual(['high']) + }) + + it('narrows a catalog model’s levels in place', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + expect(getSupportedThinkingLevels(catalogModel as Model)).toEqual(['off', 'high', 'max']) + + const model = modelOf({ + deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: { off: null, high: 'high' } }] }, + }, 'deepseek') + + expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high']) + // Only the reasoning fields change; identity and capacities stay catalog. + expect(model.name).toBe(catalogModel.name) + expect(model.contextWindow).toBe(catalogModel.contextWindow) + }) + + it('strips reasoning from a catalog model with false', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + expect(catalogModel.reasoning).toBe(true) + + const model = modelOf({ deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: false }] } }, 'deepseek') + + expect(model.reasoning).toBe(false) + expect(getSupportedThinkingLevels(model)).toEqual(['off']) + }) + + it('inherits the catalog capability when the field is absent', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + + const model = modelOf({ deepseek: { models: [{ id: catalogModel.id }] } }, 'deepseek') + + expect(model.reasoning).toBe(catalogModel.reasoning) + expect(model.thinkingLevelMap).toEqual(catalogModel.thinkingLevelMap) + }) + + it('rejects a declaration that offers nothing or spells a level it cannot send', () => { + const declare = (efforts: NonNullable): (() => unknown) => + () => resolveProfiles(declared([{ id: 'm', reasoningEfforts: efforts }])) + + expect(declare({})).toThrow(/empty reasoningEfforts/) + // A YAML `reasoningEfforts:` left valueless arrives as null through the + // schema union; it declares nothing and is not a spelling of "inherit". + expect(declare(null as never)).toThrow(/empty reasoningEfforts/) + expect(declare({ off: null })).toThrow(/offers no level beyond "off"/) + expect(declare({ off: 'none' })).toThrow(/offers no level beyond "off"/) + expect(declare({ high: null })).toThrow(/only "off" may leave it empty/) + expect(declare({ high: '' })).toThrow(/must not be an empty string/) + }) +}) + +describe('reasoning-dispatch compat switches', () => { + /** The materialized models of one route, keyed by id. */ + function modelsOf(providers: Record, route: string): Map> { + const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? [] + return new Map(models.map(model => [model.id, model])) + } + + it('applies route switches to every openai-completions model, entries winning per field', () => { + const models = modelsOf({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { thinkingFormat: 'deepseek' }, + models: [ + { id: 'dialect-default', reasoningEfforts: { off: null, high: 'high' } }, + { id: 'dialect-odd', compat: { thinkingFormat: 'openai', supportsReasoningEffort: false } }, + ], + }, + }, 'acme-gateway') + + expect(models.get('dialect-default')?.compat).toEqual({ thinkingFormat: 'deepseek' }) + expect(models.get('dialect-odd')?.compat).toEqual({ thinkingFormat: 'openai', supportsReasoningEffort: false }) + }) + + it('merges the switches over the catalog entry’s own compat instead of replacing it', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + const inherited = catalogModel.compat as OpenAICompletionsCompat + expect(inherited.requiresReasoningContentOnAssistantMessages).toBe(true) + + const models = modelsOf({ + deepseek: { models: [{ id: catalogModel.id, compat: { thinkingFormat: 'openai' } }] }, + }, 'deepseek') + + // The one switched field changes; the catalog's other quirks survive, + // because configuration has no way to restate them. + expect(models.get(catalogModel.id)?.compat).toEqual({ ...inherited, thinkingFormat: 'openai' }) + }) + + it('skips models of other protocols on a mixed route instead of failing them', () => { + // xai ships both completions and responses models, so a route-level switch + // must land on the former without invalidating the latter. + const catalog = getBuiltinModels('xai') as readonly Model[] + const completions = catalog.find(model => model.api === 'openai-completions') + const responses = catalog.find(model => model.api === 'openai-responses') + if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog') + + const models = modelsOf({ + xai: { + compat: { supportsReasoningEffort: false }, + models: [{ id: completions.id }, { id: responses.id }], + }, + }, 'xai') + + expect((models.get(completions.id)?.compat as OpenAICompletionsCompat).supportsReasoningEffort).toBe(false) + expect(models.get(responses.id)?.compat).toEqual(responses.compat) + }) + + it('rejects a model-level switch on a protocol that has no such field', () => { + expect(() => resolveProfiles({ + anthropic: { + models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }], + }, + })).toThrow(/exist only on openai-completions/) + }) + + it('rejects route switches no model on the route can take', () => { + expect(() => resolveProfiles({ + anthropic: { compat: { thinkingFormat: 'openai' } }, + })).toThrow(/no model on the route speaks openai-completions/) + }) +}) + describe('resolution snapshots', () => { it('finishes an in-flight request under the configuration it started with', async () => { const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts index 90f8487ad8..5d041c1562 100644 --- a/packages/llm/llm-pi-ai/tests/config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/config.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveProfiles } from '../src/config.ts' +import { Config, resolveProfiles } from '../src/config.ts' describe('API key format', () => { it('trims a padded literal apiKey into the resolved profile', () => { @@ -22,3 +22,33 @@ describe('API key format', () => { .toThrow(/no HTTP header can carry/) }) }) + +describe('reasoning schema boundary', () => { + const configWith = (model: Record): (() => unknown) => + () => Config({ + providers: { + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm', ...model }], + }, + }, + }) + + it('rejects a level pi-ai does not know at the write that produced it', () => { + expect(configWith({ reasoningEfforts: { ultra: 'x' } })).toThrow(/"off"/) + expect(configWith({ reasoningEfforts: { high: 42 } })).toThrow() + }) + + it('keeps false distinguishable from an absent declaration', () => { + type Materialized = { providers: Record } + const withFalse = configWith({ reasoningEfforts: false })() as Materialized + expect(withFalse.providers['acme-gateway']?.models?.[0]?.reasoningEfforts).toBe(false) + const absent = configWith({})() as Materialized + expect(absent.providers['acme-gateway']?.models?.[0]?.reasoningEfforts).toBeUndefined() + }) + + it('rejects a thinking format outside the offered set', () => { + expect(configWith({ compat: { thinkingFormat: 'quantum' } })).toThrow(/expected/) + }) +}) diff --git a/tsconfig.host.json b/tsconfig.host.json index 6884839536..0d87ec1fb7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -10,6 +10,7 @@ "include": [ "apps/web/tests/scaffold.ts", "apps/web/tests/default-model.e2e.ts", + "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", "apps/web/tests/core-web-profile.snapshot.ts", From cc0f6e11b9e108c42fd9619bfd115863e937ef5f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:55:37 +0800 Subject: [PATCH 016/100] feat(tool-skill): teach the catalog about user-explicit skill injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both catalog renderings now tell the model that a directly invoked skill arrives as an inline block to follow without re-loading it through the skill tool — the seam rule that keeps the user-explicit path and the model-autonomous path from double-injecting one skill. --- examples/acp-agent/tests/snapshots/skill-load/session.jsonl | 2 +- packages/skill/tool-skill/src/index.ts | 2 ++ packages/skill/tool-skill/tests/tool-skill.spec.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index f30dc715cf..ec369b492a 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -5,7 +5,7 @@ {"type":"step/start","seq":3,"time":1785498773754,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498773754,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1785498773755,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"3fc7e2f8-90fc-496c-b516-700cef1d86f1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1785730426818,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1785498773756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730426819,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 19e154143d..aa9b509206 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -217,6 +217,7 @@ function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessa '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', '', ].join('\n'), }], @@ -235,6 +236,7 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag ] : [ 'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.', + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', ] return createUserMessage({ content: [{ diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 0755e398a0..9543c196af 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -287,6 +287,7 @@ describe('dsh-tool-skill', () => { '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', '', ].join('\n'), }], From 756304322a22400e651f5be7ba1ccd294dd77ad7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:57:41 +0800 Subject: [PATCH 017/100] feat(llm-pi-ai): modelOverrides reshapes catalog models without replacing the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A route's modelOverrides dict customizes individual installed-catalog models — key = catalog model id, value = the same fields a models entry takes — while the rest of the catalog keeps serving, which a models list cannot express because declaring one replaces the served set. An override becomes the catalog entry's configuration and resolves through the existing entry path, so capacities, reasoningEfforts, compat, and request-default semantics are identical to a models entry's. Unlike Pi's config layer, which ignores unknown ids, every override that lands nowhere is refused at the write that produced it: beside a models list, on a hand-declared route, naming a model the catalog does not describe, or smuggling an id through the schema's unknown-key tolerance. --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 13 +++- packages/llm/llm-pi-ai/README.zh.md | 13 +++- packages/llm/llm-pi-ai/src/catalog.ts | 39 ++++++++++- packages/llm/llm-pi-ai/src/config.ts | 30 ++++++++- packages/llm/llm-pi-ai/src/index.ts | 1 + packages/llm/llm-pi-ai/tests/catalog.spec.ts | 71 ++++++++++++++++++++ 7 files changed, 164 insertions(+), 7 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 69efba1977..c8ae1899bd 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 894aecc720f0a7616c0127d439b41129d94ef667 -README.zh.md: 63464f80ee3036ddec3fb6828ecccc68c5524478 +README.md: f208f553ab3a1f80c5b71f4792e5fc80459f9fa5 +README.zh.md: 24ae4b0e2021eeea373eacb8cc1dfc39063fee8b diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 894aecc720..f208f553ab 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -35,6 +35,15 @@ Configure credentials, the model catalog, and deployment-specific transport sett models: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the + # catalog keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -68,6 +77,8 @@ The dict shape makes duplicate routes unrepresentable, and the pre-release array A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits — but declaring any `models` list means every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. The configurable entry fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. +`modelOverrides` reshapes individual installed-catalog models without that cost: each key is a catalog model id, each value the same fields a `models` entry takes with the id living in the key, and the rest of the catalog keeps serving untouched — "correct one model, keep the other thirty-seven" as a three-line edit. An override becomes that catalog entry's configuration, so capacities, efforts, and compat resolve through the same path with the same diagnostics and the same request-default semantics as a `models` entry. Overrides are only meaningful on a catalog route serving its catalog: one set beside a `models` list (which already replaces the catalog), on a hand-declared route (whose models are fully spelled in `models`), or naming a model the catalog does not describe is refused rather than skipped, because a silently unchanged model is a typo someone would otherwise hunt for. + ### Per-model reasoning efforts `reasoningEfforts` declares a model's selectable thinking levels: each key is a level selectors offer, its value the spelling dispatch sends on the wire, so `high: high` passes the canonical name through while `max: ultra` renames it for a gateway with its own vocabulary. Keys come from pi-ai's level set (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); a level not declared is not offered. Omitting the field keeps the installed catalog entry's capability (a hand-declared model has none and does not reason); `false` declares a non-reasoning model, which is how a profile strips reasoning from a catalog model its gateway cannot serve; an empty declaration is refused rather than guessing between those two meanings. @@ -98,7 +109,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 63464f80ee..24ae4b0e20 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -35,6 +35,15 @@ models: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the + # catalog keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -68,6 +77,8 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑——但一旦声明了 `models` 列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。可配置的条目字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。 +`modelOverrides` 无需这份代价就能就地重塑单个已安装 catalog 模型:每个键是一个 catalog 模型 id,每个值可写 `models` 条目接受的同一批字段,只是 id 落在键上,而 catalog 的其余部分原样继续服务——「改一个模型、其余三十七个原样保留」只是一次三行编辑。一条覆盖会成为该 catalog 条目的配置,因此容量、档位与 compat 沿与 `models` 条目相同的路径解析,携带相同的诊断与相同的请求默认值语义。覆盖只在正服务自身 catalog 的 catalog 路由上才有意义:与 `models` 列表并存的一份(该列表本就替换了 catalog)、落在手工声明路由上的一份(其模型已在 `models` 中完整写出),或点名了 catalog 未描述模型的一份,都会被拒绝而非跳过,因为一个静默保持原样的模型,就是一个否则要有人费力追查的笔误。 + ### 按模型的推理档位 `reasoningEfforts` 声明模型可选的思考级别:每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写,因此 `high: high` 原样透传规范名称,而 `max: ultra` 则为使用自有词汇的网关改名。键取自 pi-ai 的档位集合(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`);未声明的档位不会被提供。省略该字段会保留已安装 catalog 条目的能力(手工声明的模型没有这份能力,也不推理);`false` 声明一个不具备推理能力的模型,profile 正是以此从其网关无法服务的 catalog 模型上剥除推理;空声明会被拒绝,而不是在这两种含义之间去猜。 @@ -98,7 +109,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index e3c9207927..3285d1595a 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -184,6 +184,15 @@ export interface PiAiModelProfile { compat?: PiAiCompatProfile } +/** + * Customization of one installed catalog model, keyed by its id in the + * route's `modelOverrides` dict — the same fields a `models` entry may set, + * with the id living in the key. Unlike a `models` list, overrides leave the + * rest of the catalog serving untouched, which is what makes "correct one + * model, keep the other thirty-seven" a three-line edit. + */ +export type PiAiModelOverride = Omit + /** The route-level facts model materialization reads. */ export interface RouteCatalogRequest { /** Provider route key, stamped onto every materialized model. */ @@ -194,6 +203,8 @@ export interface RouteCatalogRequest { baseURL?: string /** Configured catalog; absent means the whole installed catalog for this route. */ models?: readonly PiAiModelProfile[] + /** Installed-catalog customizations by model id; only meaningful while `models` is absent. */ + modelOverrides?: Readonly> /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */ compat?: PiAiCompatProfile /** Context capacity for a model neither the entry nor the catalog sizes. */ @@ -381,9 +392,35 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { // schema materializes `[]` for the absent case, and an empty catalog could // serve no request anyway, so both mean "serve the installed catalog". const configured = request.models ?? [] + const overrides = request.modelOverrides ?? {} + // Every miss is refused, never skipped: an override that lands nowhere is a + // typo someone would otherwise hunt for in a silently unchanged model. + for (const [id, override] of Object.entries(overrides)) { + if (id.length === 0) invalid(provider, 'has a modelOverrides entry with an empty model id') + if (defaults.size === 0) { + invalid(provider, `sets modelOverrides for "${id}", but the installed catalog does not describe this route;` + + ' a declared route spells every model out in its models list') + } + if (configured.length > 0) { + invalid(provider, `sets modelOverrides for "${id}" beside a models list; models already replaces the served` + + ' catalog, so declare the fields on its entries') + } + if (!defaults.has(id)) { + invalid(provider, `modelOverrides names "${id}", which the installed catalog does not describe`) + } + // The id lives in the dict key; a value carrying its own would quietly + // rename the model it meant to customize. The static shape already omits + // it — this guards the schema boundary, which passes unknown keys through. + if ('id' in override) { + invalid(provider, `modelOverrides entry "${id}" sets "id", which is the dict key`) + } + } + // An override becomes the catalog entry's configuration, so everything a + // models entry may declare — capacities, efforts, compat — resolves through + // the same path with the same diagnostics and request-default semantics. const entries: readonly PiAiModelProfile[] = configured.length > 0 ? configured - : [...defaults.values()].map(model => ({ id: model.id })) + : [...defaults.values()].map(model => ({ id: model.id, ...overrides[model.id] })) if (entries.length === 0) { invalid(provider, 'resolves no models; the installed catalog does not describe this route, so its models' + ' must be listed in configuration') diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 9d4cca089c..d93f68bcd9 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -22,7 +22,7 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts' -import type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts' +import type { PiAiCompatProfile, PiAiModelOverride, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts' import { buildProvider, supportedProtocols } from './provider.ts' /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -34,7 +34,13 @@ export const DEFAULT_CONTEXT_WINDOW = 262_144 /** Output capability assumed for a model neither configuration nor the catalog sizes. */ export const DEFAULT_MAX_TOKENS = 32_768 -export type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts, PiAiThinkingFormat } from './catalog.ts' +export type { + PiAiCompatProfile, + PiAiModelOverride, + PiAiModelProfile, + PiAiReasoningEfforts, + PiAiThinkingFormat, +} from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { @@ -62,6 +68,15 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Installed-catalog customizations by model id: each entry reshapes that + * one model with the same fields a {@link models} entry takes, while the + * rest of the catalog keeps serving untouched. Only meaningful on a catalog + * route with no `models` list — `models` already replaces the catalog, so + * an override beside it, on a route the catalog does not ship, or naming a + * model the catalog does not describe is refused rather than skipped. + */ + modelOverrides?: Record /** * Reasoning-dispatch switches for every `openai-completions` model on this * route; each model's own `compat` overrides per field. What neither sets @@ -176,6 +191,15 @@ const modelProfile: z = z.object({ compat: compatProfile, }) +/** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */ +const modelOverride: z = z.object({ + name: z.string(), + contextWindow: z.number().step(1).min(1), + maxTokens: z.number().step(1).min(1), + reasoningEfforts: z.union([z.const(false), reasoningEfforts]), + compat: compatProfile, +}) + const profile = z.object({ apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref'), @@ -183,6 +207,7 @@ const profile = z.object({ api: z.union(supportedProtocols()), baseURL: z.string(), models: z.array(modelProfile), + modelOverrides: z.dict(modelOverride), compat: compatProfile, defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS), @@ -291,6 +316,7 @@ export function resolveProfiles( ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, ...source.models === undefined ? {} : { models: source.models }, + ...source.modelOverrides === undefined ? {} : { modelOverrides: source.modelOverrides }, ...source.compat === undefined ? {} : { compat: source.compat }, defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index ea81f66fec..e00b9f3c2a 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -70,6 +70,7 @@ export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' export type { PiAiCompatProfile, + PiAiModelOverride, PiAiModelProfile, PiAiProviderProfile, PiAiReasoningEfforts, diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index fbfcd653cf..de806558c5 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -573,6 +573,77 @@ describe('per-model reasoning efforts', () => { }) }) +describe('modelOverrides', () => { + const deepseekModel = (): Model => { + const [model] = getBuiltinModels('deepseek') + if (model === undefined) throw new Error('the installed catalog ships no deepseek model') + return model + } + + it('reshapes one catalog model while the rest of the catalog keeps serving', () => { + const catalogSize = getBuiltinModels('deepseek').length + const target = deepseekModel() + const resolved = resolveProfiles({ + deepseek: { + modelOverrides: { + [target.id]: { + name: 'DeepSeek (proxied)', + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'high' }, + }, + }, + }, + }) + const models = resolved.get('deepseek')?.piProvider.getModels() ?? [] + const reshaped = models.find(model => model.id === target.id) + if (reshaped === undefined) throw new Error('the overridden model vanished from the route') + + // The whole catalog still serves — that is the difference from `models`, + // which replaces it. + expect(models).toHaveLength(catalogSize) + expect(reshaped.name).toBe('DeepSeek (proxied)') + expect(getSupportedThinkingLevels(reshaped)).toEqual(['off', 'high']) + // An override's cap is explicit configuration, so it becomes the request + // default exactly as a models entry's would. + expect(resolved.get('deepseek')?.configuredMaxTokens.get(target.id)).toBe(4096) + // A sibling the overrides do not name is byte-identical to the catalog. + const sibling = models.find(model => model.id !== target.id) + expect(sibling?.maxTokens).toBe(getBuiltinModels('deepseek').find(model => model.id === sibling?.id)?.maxTokens) + }) + + it('refuses every override that lands nowhere instead of skipping it', () => { + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { 'no-such-model': { name: 'ghost' } } }, + })).toThrow(/which the installed catalog does not describe/) + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm' }], + modelOverrides: { m: { name: 'renamed' } }, + }, + })).toThrow(/a declared route spells every model out/) + const declaredOnly = deepseekModel() + expect(() => resolveProfiles({ + deepseek: { + models: [{ id: declaredOnly.id }], + modelOverrides: { [declaredOnly.id]: { name: 'renamed' } }, + }, + })).toThrow(/models already replaces the served catalog/) + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { '': { name: 'nameless' } } }, + })).toThrow(/empty model id/) + // The dict key is the id; a value smuggling its own would quietly rename + // the model it meant to customize. The schema passes unknown keys + // through, so resolution is the boundary that refuses it — the variable + // indirection mirrors that boundary by sidestepping the literal check. + const smuggled = { name: 'x', id: 'other' } + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { [deepseekModel().id]: smuggled } }, + })).toThrow(/sets "id", which is the dict key/) + }) +}) + describe('reasoning-dispatch compat switches', () => { /** The materialized models of one route, keyed by id. */ function modelsOf(providers: Record, route: string): Map> { From 56e9e617498a5ac9bc5a8c4b1878c776bb2c299a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:59:55 +0800 Subject: [PATCH 018/100] feat(ui-skill): claim slash skill references into skill.invoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A menu pick or an entered /name line now claims the composer into an args-tolerant skill.invoke transaction instead of shipping the literal text and hoping the model loads the skill. This gives every user-invocable skill a deterministic entry point — including disable-model-invocation skills the catalog never shows the model (issue #1470). Candidates carry a user-only hint, and the unreached legacy reference codec is removed (decision 21 removal cut). --- packages/client/connection/tests/fake-api.ts | 4 + packages/client/runtime/tests/fake-api.ts | 4 + packages/client/ui-skill/src/client/index.ts | 71 ++++++++++---- .../client/ui-skill/src/client/locales.ts | 2 + .../ui-skill/tests/browser-plugin.spec.ts | 92 +++++++++++++++---- 5 files changed, 139 insertions(+), 34 deletions(-) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index cc1062843e..bd8efaf6a4 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -163,6 +163,9 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) + onSkillInvoke: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ accepted: true as const })) + readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), @@ -170,6 +173,7 @@ export class FakeApiClient implements IApiClient { readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), + invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)), } readonly goals: IApiClient['goals'] = { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b6f2884837..def535a59a 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -198,6 +198,9 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) + onSkillInvoke: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ accepted: true as const })) + readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), @@ -205,6 +208,7 @@ export class FakeApiClient implements IApiClient { readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), + invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)), } readonly goals: IApiClient['goals'] = { diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 9631125801..7d859bf2fb 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -2,13 +2,15 @@ * Skill reference plugin, browser half: registers the '/' skill source — * candidates from the skill.list RPC addressed by the per-call session * projection's sessionId (sessions are always agent-backed; the host - * resolves cwd from the session header), pick inserts the literal `/name ` - * text (decision 21: the draft carries plain text, chip visuals are derived - * by scanning against the source lexicon, and the prompt ships the same - * literal — no `` tag). The RPC rides the plugin's root-context - * connection captured at registration — the source never reads services off - * a per-call argument. No adjudication hooks: skill references ride - * ordinary prompts and never enter command adjudication. + * resolves cwd from the session header). A menu pick or an entered `/name + * [args]` line claims into a skill.invoke transaction: the host renders the + * skill body and injects it as a user message, so invocation is + * deterministic for every user-invocable skill — including + * `disable-model-invocation` skills the model-side catalog never lists + * (issue #1470). The RPC rides the plugin's root-context connection + * captured at registration — the source never reads services off a per-call + * argument. Draft chip visuals still derive from the lexicon scan; the + * legacy `` reference codec is gone (decision 21 removal cut). * * Catalog fetches are cached per session (the small twin of the ui-command * directory): the per-keystroke candidates re-poll filters a settled @@ -25,7 +27,7 @@ */ import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' -import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { PickOutcome, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import { SkillRow } from './SkillRow.tsx' @@ -119,6 +121,30 @@ export function apply(ctx: ClientContext): void { for (const key of [...fetches.keys()]) invalidate(key) } + /** User-only marker in the active language (the menu hint is plain text, resolved at candidate time). */ + const userOnlyHint = (): string => ctx.locale.getSnapshot().active === 'zh' ? zh['menu.userOnly'] : en['menu.userOnly'] + + /** + * Args-tolerant claim for one skill: token `/name ` plus the skill.invoke + * transaction. Blank args stay off the wire; an RPC refusal folds into the + * composer's error outcome (transport failures throw). + */ + const invokeClaim = (session: { readonly sessionId: SessionId }, name: string): PickOutcome => ({ + claim: { + token: `/${name} `, + submit: async (args) => { + const trimmed = args.trim() + const { result } = await skills.invoke({ + sessionId: session.sessionId, + name, + ...trimmed === '' ? {} : { text: trimmed }, + }) + if (!result.ok) return { kind: 'error', text: `${result.error.code}: ${result.error.message}` } + return { kind: 'success' } + }, + }, + }) + const source: SlashSource = { trigger: '/', name: 'skill', @@ -129,7 +155,11 @@ export function apply(ctx: ClientContext): void { if (signal.aborted) return [] return skills .filter(skill => skill.name.startsWith(query)) - .map(skill => ({ name: skill.name, description: skill.description })) + .map(skill => ({ + name: skill.name, + description: skill.description, + ...skill.modelInvocable ? {} : { hint: userOnlyHint() }, + })) }, warm(session) { // Fire-and-forget scope-birth prewarm; the shared fetch reports @@ -149,16 +179,21 @@ export function apply(ctx: ClientContext): void { if (listeners.size === 0) lexiconListeners.delete(key) } }, - onPick({ candidate }) { - // Decision 21: plain-text reference — the literal lands in the draft - // and ships to the model verbatim (trailing space closes the token). - // Legacy path (decision 21), retained for the removal cut, no longer reached: - // return { insert: { source: 'skill', ref: candidate.name, label: candidate.name, clipboardText: `/${candidate.name}` } } - return { text: `/${candidate.name} ` } + onPick({ candidate, session }) { + return invokeClaim(session, candidate.name) }, - codec: { - clipboardText: ref => `/${ref}`, - serialize: ref => Promise.resolve(`${ref}`), + async matchEnter(session, line, signal) { + const trimmed = line.trim() + if (!trimmed.startsWith('/')) return undefined + const ws = trimmed.search(/\s/) + const name = (ws === -1 ? trimmed : trimmed.slice(0, ws)).slice(1) + if (name === '') return undefined + // Strong-wait the catalog: an unknown name stays a plain prompt (the + // default sink), never a swallowed line. + const catalog = await fetchCatalog(session.sessionId) + if (signal.aborted) return undefined + if (!catalog.some(skill => skill.name === name)) return undefined + return invokeClaim(session, name) }, } const slash = ctx.get('slash') as SlashServiceContract diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index 53746397bc..40ef78dea5 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -9,6 +9,7 @@ export const zh = { 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'menu.userOnly': '仅用户', } satisfies Record /** The skill namespace key union. */ @@ -20,4 +21,5 @@ export const en = { 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'menu.userOnly': 'user-only', } satisfies Record diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 9b047a3713..e38adf7686 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -20,11 +20,15 @@ import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client- import { apply, inject } from '../src/client/index.ts' import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' -type SkillRow = { name: string; description: string; whenToUse?: string } +type SkillRow = { name: string; description: string; whenToUse?: string; modelInvocable?: boolean } type ListResult = | { ok: true; value: { skills: SkillRow[] } } | { ok: false; error: { code: string; message: string; details: object } } type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> +type InvokeResult = + | { ok: true; value: { accepted: true } } + | { ok: false; error: { code: string; message: string; details: object } } +type InvokeFn = (payload: object) => Promise<{ result: InvokeResult }> interface PresentationCapture { slots: SlotsService @@ -49,16 +53,18 @@ function providePresentation(ctx: Context): PresentationCapture { capture.dictionaries.push({ namespace, dictionaries }) return () => { capture.localeDisposed = true } }, + getSnapshot: () => ({ active: 'zh', locales: ['zh', 'en'], revision: 0 }), }) return capture } /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */ -async function bench(list: ListFn, addressed?: SessionId) { +async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) { const ctx = new Context() let captured: SlashSource | undefined ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('connection', { api: { skills: { list } } }) + const defaultInvoke: InvokeFn = () => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }) + ctx.provide('connection', { api: { skills: { list, invoke: invoke ?? defaultInvoke } } }) ctx.provide('sessions', { subagentAddress: (id: SessionId) => id === addressed ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } @@ -70,9 +76,9 @@ async function bench(list: ListFn, addressed?: SessionId) { } const CATALOG: SkillRow[] = [ - { name: 'commit-helper', description: 'commit flow' }, - { name: 'code-review', description: 'review flow', whenToUse: 'reviews' }, - { name: 'deploy', description: 'deploy flow' }, + { name: 'commit-helper', description: 'commit flow', modelInvocable: true }, + { name: 'code-review', description: 'review flow', whenToUse: 'reviews', modelInvocable: true }, + { name: 'deploy', description: 'deploy flow', modelInvocable: true }, ] const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ result: { ok: true as const, value: { skills } } }) @@ -117,12 +123,14 @@ describe('apply', () => { 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'menu.userOnly': '仅用户', }, en: { 'row.running': 'Loading skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'menu.userOnly': 'user-only', }, }, }]) @@ -313,9 +321,10 @@ describe('lexicon', () => { }) }) -describe('pick and codec', () => { - it('onPick returns the literal /name text with a closing space (decision 21)', async () => { - const { source } = await bench(listOk(CATALOG)) +describe('pick claims into skill.invoke', () => { + it('onPick returns an args-tolerant claim whose submit invokes the skill', async () => { + const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } })) + const { source } = await bench(listOk(CATALOG), undefined, invoke) const outcome = source.onPick({ candidate: { name: 'commit-helper', description: 'commit flow' }, session: proj('s1'), @@ -323,21 +332,72 @@ describe('pick and codec', () => { via: 'menu', span: { start: 0, end: 4, draftRev: 7 }, }) - expect(outcome).toEqual({ text: '/commit-helper ' }) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') + expect(outcome.claim.token).toBe('/commit-helper ') + await expect(outcome.claim.submit('check the fixture', {} as never)).resolves.toEqual({ kind: 'success' }) + expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'commit-helper', text: 'check the fixture' }) }) - it('codec projects clipboard `/name` and serializes the model form name', async () => { + it('submit omits blank args and folds an RPC refusal into an error outcome', async () => { + const invoke = vi.fn(() => Promise.resolve({ + result: { ok: false as const, error: { code: 'skill-not-invocable', message: 'nope', details: { name: 'deploy' } } }, + })) + const { source } = await bench(listOk(CATALOG), undefined, invoke) + const outcome = source.onPick({ + candidate: { name: 'deploy', description: 'deploy flow' }, + session: proj('s1'), + position: 'leading', + via: 'menu', + span: { start: 0, end: 4, draftRev: 7 }, + }) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') + await expect(outcome.claim.submit(' ', {} as never)) + .resolves.toEqual({ kind: 'error', text: 'skill-not-invocable: nope' }) + expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy' }) + }) + + it('drops the legacy reference codec (decision 21 removal cut)', async () => { const { source } = await bench(listOk(CATALOG)) - expect(source.codec!.clipboardText('deploy')).toBe('/deploy') - await expect(source.codec!.serialize('deploy', new AbortController().signal)) - .resolves.toBe('deploy') + expect(source.codec).toBeUndefined() }) }) describe('adjudication', () => { - it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => { + it('claims an entered /name line, args-tolerant, once the catalog knows the name', async () => { + const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } })) + const { source } = await bench(listOk(CATALOG), undefined, invoke) + const outcome = await source.matchEnter!(proj('s1'), '/deploy run the smoke suite', new AbortController().signal) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') + expect(outcome.claim.token).toBe('/deploy ') + await outcome.claim.submit('run the smoke suite', {} as never) + expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy', text: 'run the smoke suite' }) + }) + + it('answers undefined for unknown names, non-slash lines, and bare "/"', async () => { + const { source } = await bench(listOk(CATALOG)) + const signal = new AbortController().signal + await expect(source.matchEnter!(proj('s1'), '/unlisted do it', signal)).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), 'plain prose', signal)).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/', signal)).resolves.toBeUndefined() + }) + + it('never claims on space (menu and enter own the skill flows)', async () => { const { source } = await bench(listOk(CATALOG)) expect(typeof source.matchSpace).toBe('undefined') - expect(typeof source.matchEnter).toBe('undefined') + }) +}) + +describe('user-only marking', () => { + it('carries the user-only hint on candidates the model cannot invoke', async () => { + const rows: SkillRow[] = [ + { name: 'shared-skill', description: 'both surfaces', modelInvocable: true }, + { name: 'user-only-skill', description: 'user surface only', modelInvocable: false }, + ] + const { source } = await bench(listOk(rows)) + const candidates = await source.candidates(proj('s1'), req('')) + expect(candidates).toEqual([ + { name: 'shared-skill', description: 'both surfaces' }, + { name: 'user-only-skill', description: 'user surface only', hint: '仅用户' }, + ]) }) }) From 011e3e4e63f4cae663bf2aa7a4523c92c9786f68 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:05:08 +0800 Subject: [PATCH 019/100] feat(client): render user skill invocations as dedicated transcript cards A user/message carrying the skill-invocation source materializes as its own conversation node (name/args lifted off the source metadata, never re-parsed from the body) and renders as a right-aligned bubble: the /name chip plus the user's trailing text, with the injected collapsed behind a disclosure. A record with an unreadable name degrades to the injected-context row. --- packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/conversation.ts | 20 ++++++++++ .../src/client/sessions/transcript-adapter.ts | 16 +++++++- .../runtime/tests/transcript-adapter.spec.ts | 25 ++++++++++++ .../src/client/chat/MessageItem.module.css | 27 +++++++++++++ .../src/client/chat/MessageItem.tsx | 39 ++++++++++++++++++- .../ui-conversation/src/client/locales.ts | 2 + .../tests/chat-branch-tails.spec.tsx | 36 +++++++++++++++++ 8 files changed, 163 insertions(+), 4 deletions(-) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 5a1677df96..a0aa4df482 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -49,7 +49,7 @@ export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - RunningToolCall, + RunningToolCall, SkillInvocationNode, SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index fb2c281331..d66faf5e95 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -129,6 +129,25 @@ export interface ContextMessageNode { form: KnownContextForm | null } +/** + * A user-explicit skill invocation: the host injected the rendered skill as a + * user message carrying the `skill-invocation` source, so the card presents + * `/name args` from source metadata and collapses the injected body. + */ +export interface SkillInvocationNode { + kind: 'skill-invocation' + seq: number + /** Unix epoch ms from the source session event. */ + time: number + /** Invoked skill name read off the message source. */ + name: string + /** Trailing user text read off the message source, when recorded. */ + args?: string + /** Full injected model-facing content (collapsed by default in the UI). */ + content: readonly ContentBlock[] + source: unknown +} + /** Durable notice that a closed failed step is waiting for a model-request retry. */ export type ModelRetryNode = LlmRetryEventData & { kind: 'model-retry' @@ -245,6 +264,7 @@ export type ConversationNode = | AssistantMessageNode | SteeringMessageNode | ContextMessageNode + | SkillInvocationNode | ModelRetryNode | TurnErrorNode | ToolResultNode diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index d970be596b..4a05afee06 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -57,7 +57,20 @@ function materializeNode( stepTimings: ReadonlyMap, ): ConversationNode { switch (event.type) { - case 'user/message': + case 'user/message': { + // A user-explicit skill invocation carries its name (and optional args) + // on the source; the dedicated node lets the card render `/name args` + // from metadata instead of re-parsing the injected body. A record whose + // name is unreadable degrades to injected context below. + const source = event.data.source as { kind?: unknown; name?: unknown; args?: unknown } + if (source.kind === 'skill-invocation' && typeof source.name === 'string') { + return { + kind: 'skill-invocation', seq: event.seq, time: event.time, + name: source.name, + ...typeof source.args === 'string' ? { args: source.args } : {}, + content: event.data.content, source: event.data.source, + } + } // Injected context (plugin/goal source) folds to a context node, not a // user message; only a direct human prompt is a user node. A compaction // checkpoint never reaches here (isCompactCheckpoint routes it away). @@ -80,6 +93,7 @@ function materializeNode( kind: 'user', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, } + } case 'assistant/message': return { kind: 'assistant', seq: event.seq, time: event.time, diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index e4ef3b0e1a..e847c2cec7 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -164,6 +164,31 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) }) + it('materializes a skill-invocation source as its dedicated node', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ + at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'body\n\ncheck the fixture' }], + source: { kind: 'skill-invocation', name: 'hidden-demo', args: 'check the fixture' } as never, + }) }), + at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'body' }], + source: { kind: 'skill-invocation', name: 'bare-skill' } as never, + }) }), + ]) + const nodes = adapter.nodes() + expect(nodes.map(node => node.kind)).toEqual(['skill-invocation', 'skill-invocation']) + expect(nodes[0]).toMatchObject({ name: 'hidden-demo', args: 'check the fixture' }) + expect(nodes[1]).toMatchObject({ name: 'bare-skill' }) + expect((nodes[1] as { args?: string }).args).toBeUndefined() + // A malformed record (no readable name) degrades to injected context, not a crash. + adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'odd' }], + source: { kind: 'skill-invocation' } as never, + }) })) + expect(adapter.nodes().at(-1)?.kind).toBe('context') + }) + it('skips events core does not call surface-eligible, marker or not', () => { // The transcript is the append-origin surface, so log-only events (a chunk, // a turn boundary, a compact/* provenance record) and a future type core diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 5c07ace71e..4330cde32c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -256,3 +256,30 @@ white-space: nowrap; vertical-align: baseline; } + +/* User-explicit skill invocation: the injected body collapses behind a + disclosure inside the user bubble. */ +.skillInvocationDetails { + margin-top: 6px; +} + +.skillInvocationSummary { + cursor: pointer; + font-size: 0.8em; + color: var(--dsw-alias-label-secondary); + user-select: none; +} + +.skillInvocationBody { + margin: 6px 0 0; + padding: 8px; + max-height: 320px; + overflow: auto; + border-radius: 6px; + background: var(--dsw-alias-bg-secondary, rgba(0, 0, 0, 0.06)); + font-family: var(--dsw-font-mono, monospace); + font-size: 0.78em; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 5473c9f8a2..661dd0cda5 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -7,8 +7,8 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, - TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode, + SteeringMessageNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' @@ -22,6 +22,7 @@ export interface MessageItemProps { | UserMessageNode | SteeringMessageNode | ContextMessageNode + | SkillInvocationNode | CompactionSummaryNode | ModelRetryNode | TurnErrorNode @@ -193,6 +194,38 @@ function UserStyleBubble({ ) } +/** + * A user-explicit skill invocation: the right-aligned bubble presents the + * `/name args` gesture from source metadata (never re-parsed from the body), + * and the injected `` collapses behind a disclosure — the + * durable content is model-facing bulk, not conversation prose. + */ +function SkillInvocationRow({ node, t }: { + node: SkillInvocationNode + t: ChatViewSlotProps['t'] +}): ReactNode { + const { text } = contentText(node.content) + return ( +
+
+ {`/${node.name}`} + {node.args !== undefined && } +
+ {t('message.skillInvocation.expand')} +
{text}
+
+
+ +
+ ) +} + /** * Render one Host-authoritative pending steering item with the same visual * language as its eventual durable transcript node. @@ -254,6 +287,8 @@ export const MessageItem = memo(function MessageItem({ t={t} /> ) + case 'skill-invocation': + return case 'compaction': return case 'model-retry': diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index df107d2cd2..a340a2f634 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -79,6 +79,7 @@ export const zh = { 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', 'message.context.recall.truncated': '已截断', 'message.steering': '插话', + 'message.skillInvocation.expand': '查看注入的 skill 内容', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', @@ -219,6 +220,7 @@ export const en = { 'message.context.recall.counts': '{retained} kept · {omitted} omitted', 'message.context.recall.truncated': 'truncated', 'message.steering': 'Interjection', + 'message.skillInvocation.expand': 'View injected skill content', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 3122b0fdc7..9471461cda 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -864,6 +864,42 @@ describe('MessageItem arms', () => { view.rerender() expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') }) + + it('skill-invocation renders the /name chip, args, and a collapsed injected body', () => { + const body = 'instructions\n\ncheck the fixture' + const view = render( + , + ) + const chip = view.container.querySelector('[data-ref-chip="skill"]') + expect(chip?.textContent).toBe('/hidden-demo') + const details = view.container.querySelector('details') + expect(details).toBeTruthy() + expect(details?.open).toBe(false) + expect(view.getByText('查看注入的 skill 内容')).toBeTruthy() + expect(view.container.querySelector('pre')?.textContent).toBe(body) + expect(view.container.querySelector('[data-skill-invocation]')).toBeTruthy() + }) + + it('skill-invocation without args renders only the chip line', () => { + const view = render( + x
' }] as never, + source: null, + }} + />, + ) + const bubble = view.container.querySelector('[data-skill-invocation]') + expect(bubble?.textContent).toContain('/bare-skill') + expect(bubble?.textContent).not.toContain('undefined') + }) }) describe('formatMessageClock', () => { From 6d09c315b93d3a5223cc31c30f99083af5530428 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:10:57 +0800 Subject: [PATCH 020/100] cleanup: remove private repository references --- ...andatory-app-attribution-headers.i18n.yaml | 4 +- ...06-21-mandatory-app-attribution-headers.md | 4 +- ...21-mandatory-app-attribution-headers.zh.md | 4 +- ...7-29-pnpm-setup-runner-isolation.i18n.yaml | 4 +- .../2026-07-29-pnpm-setup-runner-isolation.md | 2 +- ...26-07-29-pnpm-setup-runner-isolation.zh.md | 2 +- ...06-18-compaction-capability-seam.i18n.yaml | 4 +- .../2026-06-18-compaction-capability-seam.md | 2 +- ...026-06-18-compaction-capability-seam.zh.md | 2 +- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 2 +- ...2026-07-26-todo-parallel-in-progress.zh.md | 2 +- ...6-07-30-queued-manual-compaction.i18n.yaml | 4 +- .../2026-07-30-queued-manual-compaction.md | 2 +- .../2026-07-30-queued-manual-compaction.zh.md | 2 +- .../2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 +- .../feature/2026-08-05-pwsh-ui-bash-parity.md | 2 +- .../2026-08-05-pwsh-ui-bash-parity.zh.md | 2 +- ...13-documentation-site-projection.i18n.yaml | 4 +- ...026-07-13-documentation-site-projection.md | 4 +- ...-07-13-documentation-site-projection.zh.md | 4 +- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 8 +-- ...evidence-based-larger-hosted-runners.zh.md | 8 +-- ...efed-minimal-translation-updates.i18n.yaml | 4 +- ...-26-briefed-minimal-translation-updates.md | 2 +- ...-briefed-minimal-translation-updates.zh.md | 2 +- ...27-wine-windows-gates-experiment.i18n.yaml | 4 +- ...026-07-27-wine-windows-gates-experiment.md | 4 +- ...-07-27-wine-windows-gates-experiment.zh.md | 4 +- ...staller-adopts-existing-checkout.i18n.yaml | 4 +- ...7-31-installer-adopts-existing-checkout.md | 2 +- ...1-installer-adopts-existing-checkout.zh.md | 2 +- ...8-06-doc-site-carries-its-images.i18n.yaml | 4 +- .../2026-08-06-doc-site-carries-its-images.md | 4 +- ...26-08-06-doc-site-carries-its-images.zh.md | 4 +- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 4 +- .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- .../2026-06-19-acp-snapshot-tests.zh.md | 2 +- .github/workflows/ci.yml | 3 +- .../cordis-tutorial/01-first-plugin.i18n.yaml | 4 +- docs/cordis-tutorial/01-first-plugin.md | 2 +- docs/cordis-tutorial/01-first-plugin.zh.md | 2 +- .../02-lifecycle-and-effects.i18n.yaml | 4 +- .../02-lifecycle-and-effects.md | 2 +- .../02-lifecycle-and-effects.zh.md | 2 +- docs/cordis-tutorial/03-services.i18n.yaml | 4 +- docs/cordis-tutorial/03-services.md | 2 +- docs/cordis-tutorial/03-services.zh.md | 2 +- docs/cordis-tutorial/04-events.i18n.yaml | 4 +- docs/cordis-tutorial/04-events.md | 2 +- docs/cordis-tutorial/04-events.zh.md | 2 +- docs/cordis-tutorial/05-config.i18n.yaml | 4 +- docs/cordis-tutorial/05-config.md | 2 +- docs/cordis-tutorial/05-config.zh.md | 2 +- .../06-composition-and-hmr.i18n.yaml | 4 +- .../cordis-tutorial/06-composition-and-hmr.md | 2 +- .../06-composition-and-hmr.zh.md | 2 +- .../07-into-the-harness.i18n.yaml | 4 +- docs/cordis-tutorial/07-into-the-harness.md | 2 +- .../cordis-tutorial/07-into-the-harness.zh.md | 2 +- docs/cordis-tutorial/index.i18n.yaml | 4 +- docs/cordis-tutorial/index.md | 4 +- docs/cordis-tutorial/index.zh.md | 4 +- docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 4 +- .../headless-agent/tests/compaction.e2e.ts | 4 +- examples/mcp-memory/README.i18n.yaml | 4 +- examples/mcp-memory/README.md | 2 +- examples/mcp-memory/README.zh.md | 2 +- package.json | 1 + packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/attribution.ts | 3 +- packages/llm/llm/src/call-config.ts | 2 + packages/sdk/telemetry/README.i18n.yaml | 4 +- packages/sdk/telemetry/README.md | 4 +- packages/sdk/telemetry/README.zh.md | 4 +- packages/sdk/telemetry/src/reporter.ts | 6 +- scripts/install.sh | 4 +- scripts/project-doc-site.spec.ts | 6 +- scripts/project-doc-site.ts | 4 +- scripts/run-gates.spec.ts | 6 ++ scripts/run-gates.ts | 1 + .../verify-public-repository-links.spec.ts | 16 +++++ scripts/verify-public-repository-links.ts | 64 +++++++++++++++++++ website/.vitepress/config.ts | 6 +- 90 files changed, 229 insertions(+), 137 deletions(-) create mode 100644 scripts/verify-public-repository-links.spec.ts create mode 100644 scripts/verify-public-repository-links.ts diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 946d5a6117..b6788a47ef 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: 28432008c354cbbb6e364746338627a26b464b0c -2026-06-21-mandatory-app-attribution-headers.zh.md: 4fb3acd72aba4bebe751f57ac0f89f776d1f1f39 +2026-06-21-mandatory-app-attribution-headers.md: ad9d65805c8f0c96bd811b5036310d019760627e +2026-06-21-mandatory-app-attribution-headers.zh.md: 3021c7fcca00f2e929d997625c303f9a27dbf673 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index 28432008c3..ad9d65805c 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri - product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity) - version: read from the owning package's manifest via `createRequire`, never a hand-copied constant -- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) tracks making it reachable before release +- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home, which must exist before release The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override seam is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields. @@ -77,7 +77,7 @@ The landed contract: **Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`. -**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) owns creating it or correcting the final URL before release. +**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise that blocks release. **Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 4fb3acd72a..3021c7fcca 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -32,7 +32,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 -- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 跟踪在发布前使其可访问 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页,且必须在发布前实际存在 默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 @@ -77,7 +77,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 **提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 -**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 负责在发布前创建该仓库或校正最终 URL。 +**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个阻塞发布的悬空承诺。 **不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index fb5ee5debc..b1bdfe74c2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: 743535d0394cbea0374c412ba6968910ce858de4 -2026-07-29-pnpm-setup-runner-isolation.zh.md: 1e51070f88dead17b9d3f5625e337c558786aba2 +2026-07-29-pnpm-setup-runner-isolation.md: 74b672b3f90ea445ad1a8e283a5904056059b2f8 +2026-07-29-pnpm-setup-runner-isolation.zh.md: 32c667dc09e561504e8e053bf2a338ed2190d9e8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index 743535d039..74b672b3f9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -6,7 +6,7 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Problem -`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773), three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression. +`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In the reproducing run, three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index 1e51070f88..32c667dc09 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在 [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773) 中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。 +`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在复现运行中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。 ## 决策 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index f071577bdd..9e195a4b1d 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: efb37482270a7952f6af6596f9afd12f17048bcc -2026-06-18-compaction-capability-seam.zh.md: 214832923c4e24835e7b25a5bbf2b1bcd62dff42 +2026-06-18-compaction-capability-seam.md: 8dcbe74429a620027a570124383442b969c12196 +2026-06-18-compaction-capability-seam.zh.md: 27b63f29c2e6f35637185b47c882ae42e5d41088 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index efb3748227..8dcbe74429 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -131,4 +131,4 @@ The lifecycle boundary makes crash state unambiguous: - **Loop:** Tests pin pre-step after the preceding `step/end` and before the next `step/start`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. - **Manual:** Maintenance serialization, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. -- **Snapshot gap:** The summarization call is session-associated and logs `compact/summary`, but ordinary transcript replay does not derive its auxiliary response. [#1971](https://github.com/deepseek-harness/deepseek-harness/issues/1971) tracks a keyless assembled scenario with an explicit replay override. +- **Snapshot gap:** The summarization call is session-associated and logs `compact/summary`, but ordinary transcript replay does not derive its auxiliary response; keyless assembled coverage therefore needs an explicit replay override. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 214832923c..27b63f29c2 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -131,4 +131,4 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab - **循环测试:** 测试固定 pre-step 发生在前一个 `step/end` 之后、下一个 `step/start` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 - **手动测试:** 无需模型密钥即可固定 maintenance 串行化、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 -- **快照缺口:** 摘要调用与会话关联并记录 `compact/summary`,但普通 transcript(文本记录)回放不会派生其辅助响应。[#1971](https://github.com/deepseek-harness/deepseek-harness/issues/1971) 跟踪一个带显式回放 override 的无密钥组装场景。 +- **快照缺口:** 摘要调用与会话关联并记录 `compact/summary`,但普通 transcript(文本记录)回放不会派生其辅助响应;因此,要实现无密钥的组装态覆盖,就必须显式提供回放 override。 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 6b0ce3f378..8a92dd2fd1 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 2805ef894050d1b1cffe06fce59a4984d463f8d1 -2026-07-26-todo-parallel-in-progress.zh.md: 16b32daa05b10f24eacde4cec9622b2159cdef09 +2026-07-26-todo-parallel-in-progress.md: 8d047f33ab1aebd8c0de2a0a5e90e7efb1b28154 +2026-07-26-todo-parallel-in-progress.zh.md: 26b7081e7b4e3688a46a2857da91ba45328532a9 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 2805ef8940..8d047f33ab 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -37,7 +37,7 @@ The durable-log invariant deliberately does NOT follow the flag. A log written w ## The display surfaces are part of the change -Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `1/4 已完成 · ` while two others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. +Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `1/4 已完成 · ` while two others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first active item and counts the rest, so the row reports how many tasks are running instead of implying one. Naming every active item was rejected: the row is a single line, and an unbounded join would overflow it — the count degrades predictably where a list does not. The derivation sits inside the toolviews domain rather than in `contract/`, the inter-domain face: the panel computes its own counts inline and shares nothing with the row, so a contract module would declare a sharing relationship that no longer exists. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index 16b32daa05..26b7081e7b 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -37,7 +37,7 @@ Status: implemented ## 展示面是本次改动的一部分 -解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 +解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。面板重做把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 工具行改用 `toolviews/plan-summary.ts` 中的 `planSummary`。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 `contract/`(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。 diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml index f06403d181..1ceb5a5213 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md -2026-07-30-queued-manual-compaction.md: 4b7a905712a01948146b8830dfc037185162eefc -2026-07-30-queued-manual-compaction.zh.md: 15a42de3536f2da5304e77ce3cb282029856ba6d +2026-07-30-queued-manual-compaction.md: 5100808ada4b7b284228113584e577d46ff91101 +2026-07-30-queued-manual-compaction.zh.md: 29c3b921d0d59527aa1d8af1c91d085b52045377 diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md index 4b7a905712..5100808ada 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md @@ -75,7 +75,7 @@ Once a transaction has appended its start, every later failure makes one closing ### Reference implementation boundaries -[PR #835](https://github.com/deepseek-harness/deepseek-harness/pull/835) was used as a reference implementation for the command, reservation, tests, and snapshot shape, but was not merged. Its process-local `WeakSet` lock and locked/unlocked method splits were considered and not adopted because the durable bracket is the single reachable lock. +An unmerged reference implementation informed the command, reservation, tests, and snapshot shape. Its process-local `WeakSet` lock and locked/unlocked method splits were considered and not adopted because the durable bracket is the single reachable lock. That reference also carried client-side replacement-anchor machinery to preserve transcript placement. The log-ordered transcript projection already consumes compaction from event order and does not consult mutable surface positions, so those anchors were considered and not adopted. diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md index 15a42de353..29c3b921d0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md @@ -75,7 +75,7 @@ DSH 有意在调用摘要器前记录 `compact/start`。缓慢或崩溃的尝试 ### 参考实现边界 -[PR #835](https://github.com/deepseek-harness/deepseek-harness/pull/835) 用作命令、预留、测试与快照结构的参考实现,但未被合并。它的进程本地 `WeakSet` 锁与 locked/unlocked 方法拆分经过评估后未被采用,因为持久标记对是唯一可达的锁。 +一个未合并的参考实现为命令、预留、测试与快照结构提供了参考。它的进程本地 `WeakSet` 锁与 locked/unlocked 方法拆分经过评估后未被采用,因为持久标记对是唯一可达的锁。 该参考实现还包含客户端侧替换锚点机制,用于保留 transcript(文本记录)位置。按日志顺序排列的 transcript 投影已经从事件顺序消费压缩,并且不会查询可变 surface 位置,因此这些锚点经过评估后未被采用。 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index dcb3a9406b..51401adfdc 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 6bbdb0e6bc69ef1af03a6a9146f83b84754cb2a6 -2026-08-05-pwsh-ui-bash-parity.zh.md: 75f3a3ddec002acaa1755c81114b0f122ab80593 +2026-08-05-pwsh-ui-bash-parity.md: 815b448b894e9c53b4c4a2076f6b94fdd316dd35 +2026-08-05-pwsh-ui-bash-parity.zh.md: 967c5a9e1409028043dc5028fdca640ddfeb1acc diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 6bbdb0e6bc..815b448b89 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 75f3a3ddec..967c5a9e14 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 ## Decision diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 7fa4d3fbba..07e0a89d0f 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: f19d9b309aa22821a75086dc07ee302097631ba0 -2026-07-13-documentation-site-projection.zh.md: cc5e94e709f0639fd35ad81165b199cc5c9effc0 +2026-07-13-documentation-site-projection.md: d9af915754fa6a1df51a27d18d412597472aaa73 +2026-07-13-documentation-site-projection.zh.md: 5b6de4b3425b6d20335a0f8055468ced414d4034 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index f19d9b309a..d9af915754 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -18,7 +18,9 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. -The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. +The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a source link under the public `deepseek-ai/deepseek-harness-sdk` home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. + +`verify-public-repository-links` rejects internal repository remotes from tracked files. Public source links use the public home, while work tracking stays in repository metadata and source carries a TODO only when the local boundary matters to maintainers. `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index cc5e94e709..5b6de4b342 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -18,7 +18,9 @@ Status: implemented 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成公开 `deepseek-ai/deepseek-harness-sdk` 主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 + +`verify-public-repository-links` 会拒绝已跟踪文件中的内部仓库远程链接。公开源文件链接使用公开主页,而工作跟踪留在仓库元数据中;只有本地边界对维护者有意义时,源文件才保留 TODO。 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 8ccd5ca13e..1dc5d79676 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: d46b8291ec05e997728da76354354f9e36bd2fb4 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: f43712859d8351ffff45c7b6d5eb2b65015ee4c3 +2026-07-22-evidence-based-larger-hosted-runners.md: 53cc86efce9061c8f9836a17cb35ebb128085b7a +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 0484548c76cb7eed11dc4235ef024a700499ef6a diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index d46b8291ec..53cc86efce 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -26,7 +26,7 @@ The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. -An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: +An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: | Complete Linux primary | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | |---|---:|---:|---:|---:|---:|---:| @@ -40,13 +40,13 @@ The same benchmark measured the required Windows build surfaces across every pro |---|---:|---:|---:|---:|---:|---:| | Active time | 152 s | 104 s | 104 s | 92 s | 103 s | 110 s | -Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. +Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A retargeted production validation completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. -The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head candidate run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. +The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In one exact-head candidate run, Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A cacheless all-size trace completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. Host setup remains part of any comparison. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, while `actions/setup-node` spent 46.56 seconds printing cached Windows environment details after finding Node in the hosted toolcache. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. -Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit. +Inner and outer worker limits are separate controls. An exact-head 32-worker ESLint experiment slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit. The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index f43712859d..0484548c76 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -26,7 +26,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 -一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: +一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: | Linux 完整主流程 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | |---|---:|---:|---:|---:|---:|---:| @@ -40,13 +40,13 @@ Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站 |---|---:|---:|---:|---:|---:|---:| | 活动耗时 | 152 秒 | 104 秒 | 104 秒 | 92 秒 | 103 秒 | 110 秒 | -Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 +Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次重新定向的生产验证在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 -客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的候选运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 +客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在一次分支头精确的候选运行中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次无缓存的全规格运行轨迹在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 任何比较都必须计入主机设置。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上;`actions/setup-node` 从托管 toolcache 找到 Node 后,仍花费 46.56 秒输出缓存的 Windows 环境详情。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定版本的 payload 并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 -内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 +内层与外层工作线程上限是相互独立的控制机制。一次分支头精确、使用 32 个工作线程的 ESLint 实验使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于能否得到确定的覆盖率结果,而非标称核心数。 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml index be725fc905..ac859f10dd 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md -2026-07-26-briefed-minimal-translation-updates.md: a47251376771165d0eb229aaa0fb7f63589d7d77 -2026-07-26-briefed-minimal-translation-updates.zh.md: c3c1b4e845b5a45acde55884b883b1dd1e570d77 +2026-07-26-briefed-minimal-translation-updates.md: afd990b7b63adfd0e66a4726975b678d044e7cad +2026-07-26-briefed-minimal-translation-updates.zh.md: dcdb253746041a7928ab7a544ae427293bc3f2de diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md index a472513767..afd990b7b6 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md @@ -12,7 +12,7 @@ The [bilingual pairing contract](2026-07-02-bilingual-docs-and-pairing-gate.md) Pair updates run on a generated briefing instead of the guidance corpus; only new pairs still run the whole-document workflow, which is unchanged. -- **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the [incremental prompt-pipeline work](https://github.com/deepseek-harness/deepseek-harness/pull/684), whose provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. +- **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the incremental prompt-pipeline work; its provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. - **The update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document. - **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command. Before recording, `--write` stores each side's exact bytes with `git hash-object -w --stdin` and pins the blob under a content-addressed local `refs/dsh/translation-pairing/snapshots/` ref; an uncommitted last-confirmed snapshot is therefore available to the briefing generator's later `git cat-file`, not merely named by a hash that Git cannot resolve or left vulnerable to garbage collection. diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md index c3c1b4e845..dcdb253746 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -12,7 +12,7 @@ Status: implemented 配对更新基于生成的简报(briefing)运行,而非基于指导语料;只有新建配对仍走整篇文档工作流,后者保持不变。 -- **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了[增量提示词流水线工作](https://github.com/deepseek-harness/deepseek-harness/pull/684)中的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 +- **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了增量提示词流水线工作的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 - **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 - **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。写下记录之前,`--write` 用 `git hash-object -w --stdin` 存入每一侧的精确字节,并在内容寻址的本地 `refs/dsh/translation-pairing/snapshots/` ref 下固定该 blob;未提交的上次确认快照因此能被简报生成器之后的 `git cat-file` 取回,而不只是留下一个 Git 无法解析的 hash 名称或暴露于垃圾回收。 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index 4786909a7f..19945a6580 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: 640c8e455b1a35ea4ac83454227147b9979316dc -2026-07-27-wine-windows-gates-experiment.zh.md: 67f59a93d1b1fad98e36e6f9c51bc77abb9e3d07 +2026-07-27-wine-windows-gates-experiment.md: 1b01fe00dc1588482442a3cedc35eefa7fdb0975 +2026-07-27-wine-windows-gates-experiment.zh.md: b50739f5b63c5836248c94927037e38900abc84f diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md index 640c8e455b..1b01fe00dc 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -14,7 +14,7 @@ The question the experiment answered: can a plain Linux runner produce an equiva The required pull-request `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) (`windows node 24 / wine blocking`) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a checksum-verified win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. The master `serial-windows` job is untouched: the complete native-kernel inventory, including the observational portability gates this lane does not run, still executes on real `windows-2025` on every master push. -Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: the independent prototype in [PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; #689's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). +Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: an independent prototype kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; the prototype's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope. @@ -32,7 +32,7 @@ Measured on 2026-07-27, warm caches, pull-request trigger, standard 2-core `ubun **A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs (40m19s measured end-to-end on the sibling experiment branch `exp/kvm-windows-ci`). Promotable only with disk-image caching that pressures the Actions cache budget. -**Windows pnpm performing the install under Wine ([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689)).** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. +**Windows pnpm performing the install under Wine.** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. **Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`; complementary to, not competitive with, this lane. diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index 67f59a93d1..b50739f5b6 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -14,7 +14,7 @@ Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 wo [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。 -依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装契约在此仍由 Linux 侧验证)。 +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:一个独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了该原型的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装契约在此仍由 Linux 侧验证)。 该通道靠四个杠杆把墙钟时间保持在与 Linux CI 作业相当的水平:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 @@ -32,7 +32,7 @@ Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 wo **在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可投入使用。 -**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 +**在 Wine 下由 Windows pnpm 执行安装。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 **Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 故障类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 79ea52067d..77003539ac 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: ff02fe837f2ad4deb3fb852f610f3cd3ff9a23d7 -2026-07-31-installer-adopts-existing-checkout.zh.md: 28816c80764acc0d4a2fcd13b3b8a38807021fd6 +2026-07-31-installer-adopts-existing-checkout.md: 3a213a6232e57f305910240505983421dcd288ad +2026-07-31-installer-adopts-existing-checkout.zh.md: 7cdcd5549fb2bca2a6cb11f0cf2867687565ae79 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index ff02fe837f..3a213a6232 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -46,6 +46,6 @@ A container adopting an outside clone is also no longer self-contained: deleting ## Testing -`scripts/install.sh` now has a real-shell PTY regression suite in `apps/cli/tests/install-script.spec.ts`, covering adoption and curl-style paths with stubbed dependencies. The installer's longer-term deletion in favor of pnpm/npx is tracked in [#1890](https://github.com/deepseek-harness/deepseek-harness/issues/1890). +`scripts/install.sh` now has a real-shell PTY regression suite in `apps/cli/tests/install-script.spec.ts`, covering adoption and curl-style paths with stubbed dependencies. Curl-style installs default to the public `deepseek-ai/deepseek-harness-sdk` source, while replacing the installer with pnpm/npx remains separate work. Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting the built layout, which is the regression that caught the unresolved-`REPO_ROOT` defect. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 28816c8076..7cdcd5549f 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -46,6 +46,6 @@ Status: implemented ## Testing -`scripts/install.sh` 现有一套位于 `apps/cli/tests/install-script.spec.ts` 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。[#1890](https://github.com/deepseek-harness/deepseek-harness/issues/1890) 跟踪安装器的长期删除工作,届时将改用 pnpm/npx。 +`scripts/install.sh` 现有一套位于 `apps/cli/tests/install-script.spec.ts` 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。curl 风格安装默认使用公开的 `deepseek-ai/deepseek-harness-sdk` 源,而以 pnpm/npx 替换安装器仍是另一项工作。 验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml index 32b51699e2..8ba8380bf1 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md -2026-08-06-doc-site-carries-its-images.md: 9109808874579b79d85c2e22b0987110f41ddc42 -2026-08-06-doc-site-carries-its-images.zh.md: d601112e8870150c363d8533e85ef86e7f3f8ffc +2026-08-06-doc-site-carries-its-images.md: 4078a9b6251cf67456590ae25602a9f288c88dc1 +2026-08-06-doc-site-carries-its-images.zh.md: a9afb138d45d1ab991963b997e408477cf88110b diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md index 9109808874..4078a9b625 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md @@ -20,7 +20,7 @@ Only a regular file whose real path stays inside the repository is copied; anyth `docsSourceFiles()` reports the placed images alongside the Markdown, so the dev server's watcher re-projects when a screenshot is replaced instead of serving the previous copy until something touches the page. -`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text. +`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the GitHub-raw fallback points at the public source home, which keeps that seam honest for a consumer that only rewrites text. Canonical Markdown keeps writing ordinary repository-relative image paths, so the same file renders on GitHub and on the site. No document carries a site-absolute URL to satisfy VitePress. @@ -36,7 +36,7 @@ Canonical Markdown keeps writing ordinary repository-relative image paths, so th Images in published documentation now work regardless of who is reading or whether the repository is public, and the site build has no runtime dependency on GitHub for them. The generated tree grows by one copy of each referenced image per locale — the four screenshots in the model-provider guide add roughly 270 KB per locale. -Images referenced from *unpublished* documents are untouched: they still resolve to GitHub raw, and still fail for a private repository. Nothing consumes them today, and a document that is not on the site has no site build to carry its assets. +Images referenced from *unpublished* documents are untouched. A text-only projection resolves them against the public source home; a document that is not on the site has no site build to carry its assets. ## Testing diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md index d601112e88..a9afb138d4 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -20,7 +20,7 @@ Status: implemented `docsSourceFiles()` 会连同被安置的图片一起上报,于是替换截图时开发服务器的 watcher 会重新投影,而不是一直服务旧副本直到有人碰一下页面。 -`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。 +`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时,GitHub raw 回退会指向公开源主页;这让该 seam 对只改写文本的消费方保持诚实。 正本 Markdown 照旧写普通的仓库相对图片路径,因此同一份文件在 GitHub 上和站点上都能正常显示。没有任何文档为了迁就 VitePress 而写站内绝对 URL。 @@ -36,7 +36,7 @@ Status: implemented 已发布文档中的图片,现在无论谁在阅读、无论仓库是否公开都能显示,站点构建也不再为图片依赖 GitHub 的运行时可达性。生成树会为每个 locale 各增加一份被引用图片的副本——配置模型指南里的四张截图,每个 locale 约 270 KB。 -**未发布**文档引用的图片不受影响:它们仍解析到 GitHub raw,对私有仓库仍然失败。今天没有任何消费方用到它们,而不在站点上的文档也没有站点构建可以承载其资源。 +**未发布**文档引用的图片不受影响。纯文本投影会相对于公开源主页解析它们;不在站点上的文档没有站点构建可以承载其资源。 ## Testing diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index b7d396e007..202a27ed1e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: 39d3b7a3f4699ea96262f43c63a7d60574ba064f -2026-06-19-acp-snapshot-tests.zh.md: 7dd3a3fa83682c35945314c7cd9531ca72bbb1fb +2026-06-19-acp-snapshot-tests.md: c7b95bd68027705b99d850d596405e56eea0dfca +2026-06-19-acp-snapshot-tests.zh.md: 43d43262684920cae5feedb5f2eb109db00f9f8c diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index 39d3b7a3f4..c7b95bd680 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -80,6 +80,6 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log ## Consequences -The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here, while [#1970](https://github.com/deepseek-harness/deepseek-harness/issues/1970) tracks moving it to a transport-neutral headless suite without losing coverage. +The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here until it can move to a transport-neutral headless suite without losing coverage. This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP. diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 7dd3a3fa83..43d4326268 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -80,6 +80,6 @@ Status: implemented ## 后果 -该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,而 [#1970](https://github.com/deepseek-harness/deepseek-harness/issues/1970) 跟踪在不损失覆盖的情况下将其迁移到传输无关的 headless 套件。 +该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,直至它能够在不损失覆盖的情况下迁移到传输无关的 headless 套件。 本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用回放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66b3a5399e..46ab8e892f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,7 @@ env: jobs: - # https://github.com/deepseek-harness/deepseek-harness/issues/1967 tracks - # restoring the three hosted serial reference jobs before release. + # TODO(hosted-serial-ci): Re-enable the three hosted serial reference jobs before release. # The self-hosted standby remains active on every master push. # Three enterprise jobs isolate coverage, static analysis, and the diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 4e829dcb8f..29e0266689 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: c44e7f95fb11d5337ecfaf4251c8b2f2b9b14680 -01-first-plugin.zh.md: 9461884d312ad2e64af12fa42952a986e1ad5d8a +01-first-plugin.md: 4359dfe4883f12e9cb242cf3009827fd7864768c +01-first-plugin.zh.md: 62ccb7e5d37beb5b9636439e563cea6eaa1044a0 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index c44e7f95fb..4359dfe488 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -92,4 +92,4 @@ One caveat worth knowing early: a config entry whose module cannot be **resolved Next: [Lifecycle and effects](02-lifecycle-and-effects.md) — what happens when a plugin unloads. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index 9461884d31..62ccb7e5d3 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -92,4 +92,4 @@ export function apply(ctx: Context) { 下一章:[生命周期与 effect](02-lifecycle-and-effects.md):插件卸载时会发生什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml index deed723c39..12793267e2 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/02-lifecycle-and-effects.md -02-lifecycle-and-effects.md: f1b39e06e9d25c51ab2d76503025e2b6ffe90c73 -02-lifecycle-and-effects.zh.md: 2e98e3af6d2f2b1b9cbb8ea38559bc1ffbf7e43b +02-lifecycle-and-effects.md: 7b195b63a1e8730f27b9dd9af8af6a68a588cee9 +02-lifecycle-and-effects.zh.md: 4a3f83dedd5c95c7fcb5c1aebbbb8cb2e849b9cf diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md index f1b39e06e9..7b195b63a1 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -95,4 +95,4 @@ One ordering caveat: disposers start in reverse registration order, but multiple Next: [Services](03-services.md) — how plugins share capabilities. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md index 2e98e3af6d..4a3f83dedd 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md @@ -95,4 +95,4 @@ PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 下一章:[服务](03-services.md):插件如何共享功能。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml index b116270811..2849ed8858 100644 --- a/docs/cordis-tutorial/03-services.i18n.yaml +++ b/docs/cordis-tutorial/03-services.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/03-services.md -03-services.md: 5848132c6ad18338fa893954d45fc20005db6199 -03-services.zh.md: 0f599f082573364e6ad38278e1914d8faf67faa1 +03-services.md: 562b49ede0aa4cc1d58c4d6af7c7d5d1ebb2e4b1 +03-services.zh.md: 964f7e3654614d136b8765bb727f85a5a05587a8 diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md index 5848132c6a..562b49ede0 100644 --- a/docs/cordis-tutorial/03-services.md +++ b/docs/cordis-tutorial/03-services.md @@ -95,4 +95,4 @@ Service names live in one flat namespace per application. Prefix or namespace yo Next: [Events](04-events.md) — communication without a shared service. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md index 0f599f0825..964f7e3654 100644 --- a/docs/cordis-tutorial/03-services.zh.md +++ b/docs/cordis-tutorial/03-services.zh.md @@ -95,4 +95,4 @@ export function apply(ctx: Context) { 下一章:[事件](04-events.md):无需共享服务即可通信。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml index 6d21e5ff1b..e7dc182114 100644 --- a/docs/cordis-tutorial/04-events.i18n.yaml +++ b/docs/cordis-tutorial/04-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/04-events.md -04-events.md: 18f39dc1b693e5fb7e1793ec4b7dcac9cf24db95 -04-events.zh.md: 3fdafb50303f49dca179bcaea32db211a66241f6 +04-events.md: 28ccb85d657afaabb5c6b4b1e9b10d6cf8710918 +04-events.zh.md: f78c971dcd9674d2a256c41000b627aecb2a572a diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md index 18f39dc1b6..28ccb85d65 100644 --- a/docs/cordis-tutorial/04-events.md +++ b/docs/cordis-tutorial/04-events.md @@ -141,4 +141,4 @@ The harness uses waterfalls for decisions that cooperating plugins may wrap or a Next: [Configuration](05-config.md) — plugin options from `cordis.yml`. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md index 3fdafb5030..f78c971dcd 100644 --- a/docs/cordis-tutorial/04-events.zh.md +++ b/docs/cordis-tutorial/04-events.zh.md @@ -141,4 +141,4 @@ harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`ag 下一章:[配置](05-config.md):来自 `cordis.yml` 的插件选项。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index db300b1745..7db45165c4 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/05-config.md -05-config.md: 8d4043e33a58fc425d82d9846ff82473bcdef4c1 -05-config.zh.md: e9463bd34e9c72dbae7b1ceb9907e35edf7b773b +05-config.md: 834bb140cc1ff976acc8f21c8f54a7fb02636eac +05-config.zh.md: f5cc6ac1ca4fa02eba6a1b015b9f6ae3b1a925fc diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index 8d4043e33a..834bb140cc 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -81,4 +81,4 @@ The loader used in this repo supports a `!!js` tag for config values that must b Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index e9463bd34e..f5cc6ac1ca 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -81,4 +81,4 @@ ValidationError: invalid config: 下一章:[组合与 HMR(热模块替换)](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml index f7abfca742..44b59db26a 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml +++ b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/06-composition-and-hmr.md -06-composition-and-hmr.md: 66d6a9d93fe39baa881940ba32388979e2678505 -06-composition-and-hmr.zh.md: 7c0a94b0abcc0f153f59391fd009f1e0b40500e5 +06-composition-and-hmr.md: f138918a2d217ed98fdfd4e56dffddc14e3397f0 +06-composition-and-hmr.zh.md: a678e86735c6d9e3b4f3cd0dfa46a2a64079c762 diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md index 66d6a9d93f..f138918a2d 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -110,4 +110,4 @@ needs-timer is PENDING — a required service is missing Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md index 7c0a94b0ab..a678e86735 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.zh.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md @@ -110,4 +110,4 @@ needs-timer is PENDING — a required service is missing 下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index 5faa0bf213..f3dde47f3a 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: 6ec42c50fe5059955734fe7bc46117538dafaffc -07-into-the-harness.zh.md: 903adb903aa4c4355b92eb34e89f218a0295767c +07-into-the-harness.md: e02f8f8d55b3fbe9087d46f8f50baeecb592c1c6 +07-into-the-harness.zh.md: 5f770267e8f3db04cd9cb0e92b6a6278cf05d5e4 diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 6ec42c50fe..e02f8f8d55 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -104,4 +104,4 @@ Where to go next: - The generated [services](../cordis-catalog/services.md) and [events](../cordis-catalog/events.md) catalogs — everything you can inject and listen to. - [Architecture](../architecture.md) — the system map these plugins live in. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 903adb903a..5f770267e8 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -104,4 +104,4 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 - 生成的[服务](../cordis-catalog/services.md)与[事件](../cordis-catalog/events.md)目录:可以注入和监听的所有内容。 - [架构](../architecture.md):这些插件所处的系统地图。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 256bc4f629..496a3fffa5 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md -index.md: af622ad4e35829c6283c40f1b0019d7959dac973 -index.zh.md: 0b7684a9532a1efdcc3ea2d067da23852d146e2f +index.md: a20976706f520416236ca759ee33649d1601eaa9 +index.zh.md: f6989521d4b7dffac6114867cc12371af4e4316f diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index af622ad4e3..a20976706f 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -13,7 +13,7 @@ If you want the condensed concept reference instead of a walkthrough, read the [ You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly. ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ The examples use three TypeScript features beyond ordinary modern JavaScript: Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index 0b7684a953..f6989521d4 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -13,7 +13,7 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ node --import tsx ../../vendor/cordis/bin.js 第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 27592cdb74..aefb76991f 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: 72cc5a52c33faf81098f747799692b384fcd5f1a -quickstart.zh.md: ebb831c1e1bec02117c78a4c2426a84af3bb9478 +quickstart.md: 8b84017ad33bf02579891bc4dcf83eaf7ec39022 +quickstart.zh.md: dfb24f8fa194866908406c709d059bf1f6595d59 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 72cc5a52c3..8b84017ad3 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -19,7 +19,7 @@ pnpm -v ## Step 1: install and configure the API key ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index ebb831c1e1..dfb24f8fa1 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -19,7 +19,7 @@ pnpm -v ## 第一步:安装并配置 API key ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b2fd25eb4e..386926e51b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -90,8 +90,8 @@ async function prepareFsSearchWorkspace(cwd: string): Promise { } } -// https://github.com/deepseek-harness/deepseek-harness/issues/1970 tracks moving -// backend/product scenarios to headless while retaining ACP protocol contracts here. +// TODO(acp-snapshot-ownership): Move backend/product scenarios to headless while +// retaining ACP protocol contracts here. function fixtureRecords(name: string): unknown[] { return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8') diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index fdae11d2c7..39d054a2f5 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -10,8 +10,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' /** * Key-gated smoke for mid-session compaction. It verifies the compact event * pair, replacement of older surface nodes, and a final answer after compaction. - * A keyless assembled snapshot with an explicit summarization replay override - * is tracked in https://github.com/deepseek-harness/deepseek-harness/issues/1971. + * TODO(compaction-snapshot): Add a keyless assembled snapshot with an explicit + * summarization replay override. */ let workdir: string | undefined diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index f89035cfcc..7e8e5248e4 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/mcp-memory/README.md -README.md: 023e6aefce0e78cbbf52620426376e1dd0a6b8cf -README.zh.md: 44ace680cd583f41903437a69c62e30817308ba2 +README.md: 792bb31b668b427c8734286878a9ec98071190d8 +README.zh.md: 51020f5288c4fbd245914280b8e7e4772e8cad69 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 023e6aefce..792bb31b66 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -36,7 +36,7 @@ Without a repository checkout, download the selected overlay directly: mkdir -p "${DSH_HOME:-$HOME/.dsh}" curl --fail --location \ --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml + https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ``` diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 44ace680cd..51020f5288 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -36,7 +36,7 @@ dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" mkdir -p "${DSH_HOME:-$HOME/.dsh}" curl --fail --location \ --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml + https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ``` diff --git a/package.json b/package.json index b1ad6853fc..eaa1025974 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "doc-typecheck": "tsx scripts/doc-typecheck.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", + "verify-public-repository-links": "tsx scripts/verify-public-repository-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 5225ba9d8c..30660e1032 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 956bfa112d6fe50c35359cebdf3710064da8c130 -README.zh.md: 42f2da18089e7dcfc9acb95076ab8786c798444b +README.md: ddbc2ea482ca0848fb0ee0813839cf5ff1829bcc +README.zh.md: 7a3b615d134e27d7c9892d6f411066d89938175b diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 956bfa112d..ddbc2ea482 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -97,5 +97,5 @@ Pass-through; the registry preserves the assembled request prefix, while the sel - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. -- **`APP_IDENTITY.url` names a repository that does not exist yet** — [#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) tracks making the public home reachable before release. +- **`APP_IDENTITY.url` names a repository that does not exist yet** — the public home must be reachable before release. - **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would cycle; a future ids-owning package would dissolve the workaround. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 42f2da1808..7a3b615d13 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -97,5 +97,5 @@ - **`GenerateOptions` 采样只包含 `temperature`/`maxTokens`/`stop`**:没有 `tool_choice`、`top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见 [已删除惰性旋钮](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md))。 - **受产生方约束的变体在实际产生前不会加入**:`prefill`、每工具 `strict`、块 `cache` 提示与 `agent` 消息源变体因没有产生方而被剪除(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。 - **`BlockAssembler` 只处理核心块类型**:如果插件添加块类型的流从未由 `block-end` 关闭,`blocks()` 会抛出异常。 -- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:[#1972](https://github.com/deepseek-harness/deepseek-harness/issues/1972) 跟踪在首次发布前让该公开主页可访问。 +- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:该公开主页必须在首次发布前可访问。 - **`GenerateOptions.sessionId` 是本地声明的品牌类型**:导入 dsh-session 的 `SessionId` 会产生循环;未来拥有 id 的包可以消除该权宜之计。 diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index b9375b6ef9..79cef011de 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -40,8 +40,7 @@ export interface AppIdentity { export const APP_IDENTITY: AppIdentity = { product: 'deepseek-harness', version, - // The public-home release blocker is tracked in - // https://github.com/deepseek-harness/deepseek-harness/issues/1972. + // TODO(public-home): Ensure this public source repository exists before release. url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', } diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index 2daa6d1a4c..848654ad3b 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -12,6 +12,8 @@ import type { ReasoningEffortId } from './brand.ts' /** Process-local identities of request objects assembled by dsh-agent-loop. */ const AGENT_LOOP_REQUESTS = new WeakSet() +// TODO(call-config-shape): Revisit which fields are epoch-level for cache reuse +// and where provider-specific request options belong. /** * Provider, model, reasoning effort, and sampling scalars of one conversation's * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; diff --git a/packages/sdk/telemetry/README.i18n.yaml b/packages/sdk/telemetry/README.i18n.yaml index 987dc8197c..1604b40bf4 100644 --- a/packages/sdk/telemetry/README.i18n.yaml +++ b/packages/sdk/telemetry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/telemetry/README.md -README.md: c9f66a2415c91b75105b0ed025470da234b2523d -README.zh.md: bfb154e4c7c017292b5479e50ff376cbb9470682 +README.md: c87735a93e7659f2913f4dd325176a8ae40cf29b +README.zh.md: 24d6e72d988f94cdbc6aa01607edbfc105213267 diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index c9f66a2415..c87735a93e 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -14,7 +14,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. -The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) tracks deploying the service and replacing its fail-safe `.invalid` placeholder before release. +The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its fail-safe `.invalid` placeholder must be replaced with the real endpoint before release. ## Model Experience @@ -26,5 +26,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the service tracked in [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) is ready. +- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set. - **Redaction is heuristic** — a conservative backstop, not a guarantee; secrets belong in `.env`, which is never read or reported. diff --git a/packages/sdk/telemetry/README.zh.md b/packages/sdk/telemetry/README.zh.md index bfb154e4c7..24d6e72d98 100644 --- a/packages/sdk/telemetry/README.zh.md +++ b/packages/sdk/telemetry/README.zh.md @@ -14,7 +14,7 @@ Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemetry 就是禁用该配置项。telemetry 默认上报,只有已经存在的 telemetry 配置项被显式设为 `disabled` 时才关闭:缺少 `cordis.yml`(首次 `create`)、配置项已启用,或 `cordis.yml` 中没有 telemetry 配置项时都会上报。`DO_NOT_TRACK`/CI 始终拒绝。无配置与缺少配置项的默认值可以通过 `ConsentResolver` 配置。 -收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);[#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) 跟踪服务部署,以及发布前将作为安全兜底的 `.invalid` 占位值替换为真实端点。 +收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);发布前必须将作为安全兜底的 `.invalid` 占位值替换为真实端点。 ## 模型体验 @@ -26,5 +26,5 @@ Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemet ## 已知限制与暂缓事项 -- **占位端点**:`DSH_TELEMETRY_ENDPOINT` 指向 `.invalid`,直至 [#1973](https://github.com/deepseek-harness/deepseek-harness/issues/1973) 跟踪的服务就绪。 +- **占位端点**:`DSH_TELEMETRY_ENDPOINT` 指向 `.invalid`,直到设置真实端点。 - **脱敏依赖启发式规则**:这只是保守后备,不是保证;密钥应存放于 `.env`,而该文件绝不会被读取或上报。 diff --git a/packages/sdk/telemetry/src/reporter.ts b/packages/sdk/telemetry/src/reporter.ts index 3ad7b4b62e..b7ac75a23f 100644 --- a/packages/sdk/telemetry/src/reporter.ts +++ b/packages/sdk/telemetry/src/reporter.ts @@ -17,10 +17,10 @@ import { SecretRedactor } from './secret-redactor.ts' /** * Fail-safe placeholder collection endpoint. The `.invalid` TLD guarantees - * delivery fails harmlessly until the service tracked in - * https://github.com/deepseek-harness/deepseek-harness/issues/1973 is ready. - * This is a fixed protocol constant, not a deployment tunable. + * delivery fails harmlessly until a collector is deployed. This is a fixed + * protocol constant, not a deployment tunable. */ +// TODO(telemetry-endpoint): Replace the placeholder before release. export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk' /** Wire-envelope schema version; bump on any incompatible body change. */ diff --git a/scripts/install.sh b/scripts/install.sh index 59184d91ab..f290782892 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,7 +1,7 @@ #!/bin/sh # dsh one-line installer. # -# curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/scripts/install.sh | sh # # It clones the harness under ~/.dsh/source (the master clone at # ~/.dsh/source/master), adds a per-install staging worktree at @@ -50,7 +50,7 @@ set -eu DSH_REF=${DSH_REF:-master} -DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git} +DSH_REPO=${DSH_REPO:-https://github.com/deepseek-ai/deepseek-harness-sdk.git} # DSH_SOURCE is the staging-worktree container and the default home of `current`. # DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE, # while adoption discovers an existing clone anywhere on disk. Remember whether diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 6770381526..89e417558c 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -104,7 +104,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[B](./reference/b.md#part) ' - + '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) ' + + '[source](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/packages/tool.ts#L2) ' + '[web](https://example.com)\n', ) }) @@ -130,7 +130,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - })).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n') + })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/abc123/packages/logo.svg)\n') }) it('hands an image to the placer and uses the URL it returns', () => { @@ -209,7 +209,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[title](./reference/b.md "b.md") ' - + '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n', + + '[escaped](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/docs/x(y).md)\n', ) }) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 02a64b023a..e3397237a3 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -15,7 +15,7 @@ import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts' -const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness' +const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness-sdk' const root = resolve(import.meta.dirname, '..') const generatedRoot = resolve(root, 'website/.generated') @@ -203,7 +203,7 @@ function githubTarget( image: boolean, ): string { const path = repoPath(absPath, repoRoot) - if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}` + if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/${repositoryRef}/${path}${suffix}` const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob' const lineSuffix = line === undefined ? suffix : `#L${line}` return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}` diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 84eeeb10bb..6979fb894d 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -77,6 +77,12 @@ describe('gate graph validation', () => { await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length) }) + it('keeps the public repository link policy in the documentation gate', () => { + const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id)) + + expect(ids).toContain('public-repository-links') + }) + it.each([ ['empty', [], /gate graph has no gates/], ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index c1c3e1699c..30288170f9 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -572,6 +572,7 @@ function docSyncLeafGates(options: { pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }), pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }), pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }), + pnpmScript('public-repository-links', 'verify-public-repository-links', { label: 'public repository links' }), pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }), pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }), pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }), diff --git a/scripts/verify-public-repository-links.spec.ts b/scripts/verify-public-repository-links.spec.ts new file mode 100644 index 0000000000..b05dcb65d1 --- /dev/null +++ b/scripts/verify-public-repository-links.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { findInternalRepositoryReferences } from './verify-public-repository-links.ts' + +describe('public repository link policy', () => { + it('rejects the internal remote and accepts the public home', () => { + const internalRepository = ['deepseek-harness', 'deepseek-harness'].join('/') + const source = [ + 'https://github.com/deepseek-ai/deepseek-harness-sdk', + `https://github.com/${internalRepository}/issues/1`, + ].join('\n') + + expect(findInternalRepositoryReferences('subject.md', source)).toEqual([ + { file: 'subject.md', line: 2 }, + ]) + }) +}) diff --git a/scripts/verify-public-repository-links.ts b/scripts/verify-public-repository-links.ts new file mode 100644 index 0000000000..dc8d2b3b35 --- /dev/null +++ b/scripts/verify-public-repository-links.ts @@ -0,0 +1,64 @@ +/** Reject tracked files that expose the internal repository remote. */ + +import { execFileSync } from 'node:child_process' +import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const root = resolve(import.meta.dirname, '..') +const internalRepository = ['deepseek-harness', 'deepseek-harness'].join('/') + +/** One tracked reference to the internal repository. */ +export interface InternalRepositoryReference { + /** Repository-relative file path. */ + file: string + /** One-based source line. */ + line: number +} + +/** + * Locate internal-repository references in one text file. + * @param file - Repository-relative path used in diagnostics. + * @param source - Text to inspect. + * @returns every matching source line. + */ +export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] { + const references: InternalRepositoryReference[] = [] + for (const [index, line] of source.split('\n').entries()) { + if (line.includes(internalRepository)) references.push({ file, line: index + 1 }) + } + return references +} + +function trackedFiles(repoRoot: string): string[] { + return execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, encoding: 'utf8' }) + .split('\0') + .filter(file => file !== '') +} + +function scanRepository(repoRoot: string): InternalRepositoryReference[] { + const references: InternalRepositoryReference[] = [] + for (const file of trackedFiles(repoRoot)) { + const path = resolve(repoRoot, file) + if (!existsSync(path)) continue + const stat = lstatSync(path) + if (!stat.isFile() && !stat.isSymbolicLink()) continue + const source = stat.isSymbolicLink() ? readlinkSync(path) : readFileSync(path, 'utf8') + if (source.includes('\0')) continue + references.push(...findInternalRepositoryReferences(file, source)) + } + return references +} + +const invokedPath = process.argv[1] +const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href +if (isMain) { + const references = scanRepository(root) + if (references.length === 0) { + console.log('verify-public-repository-links: tracked files expose no internal repository remote.') + } else { + console.error('verify-public-repository-links: internal repository references found:') + for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`) + process.exitCode = 1 + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index da3892eaa2..4dc1a5774b 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -94,14 +94,14 @@ const sharedTheme: Pick { const data: unknown = frontmatter const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') - return `https://github.com/deepseek-harness/deepseek-harness/edit/master/${editSource}` + return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` }, text: '在 GitHub 上编辑此页', }, @@ -161,7 +161,7 @@ export default withMermaid({ const data: unknown = frontmatter const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') - return `https://github.com/deepseek-harness/deepseek-harness/edit/master/${editSource}` + return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` }, text: 'Edit this page on GitHub', }, From 8ccb17690579970ff2430448860f847799c13b78 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:17:10 +0800 Subject: [PATCH 021/100] docs: per-model reasoning guide, config catalog, and the feature's Agent Note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user guide's model-catalog section teaches the three new knobs at task altitude — declare levels per model, pick the reasoning dialect, reshape catalog models with modelOverrides — with the settings.yaml example exercising all of them, plus an UNSUPPORTED_REASONING_EFFORT troubleshooting row. The generated plugin config catalog picks up the new Config fields, and the bilingual Agent Note records the decision, the alternatives considered, and the schemastery materialization constraint that chose false over {} as the disable spelling. --- ...per-model-reasoning-declarations.i18n.yaml | 6 ++ ...-pi-ai-per-model-reasoning-declarations.md | 33 ++++++++ ...-ai-per-model-reasoning-declarations.zh.md | 33 ++++++++ docs/config-catalog.md | 78 ++++++++++++++++++- docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 32 +++++++- docs/user/guide/providers.zh.md | 32 +++++++- 7 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml new file mode 100644 index 0000000000..3b448f4cf1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md +2026-08-08-pi-ai-per-model-reasoning-declarations.md: 436b5f3f9f30c1bb1dc5816b12ce1596c5d01ec8 +2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 47b34dfd270f90fef2802a00e3632777d5636a73 diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md new file mode 100644 index 0000000000..436b5f3f9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md @@ -0,0 +1,33 @@ +# Agent Note: Per-Model Reasoning Declarations in llm-pi-ai + +Status: implemented + +English | [中文](2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md) + +## Problem + +A hand-declared pi-ai route's models materialized with `reasoning: false`, so `getSupportedThinkingLevels` short-circuited to `["off"]`: the composer offered no effort picker for them, and the route-level `reasoning` default — the only reasoning knob a profile had — made every request to such a model fail with `UNSUPPORTED_REASONING_EFFORT` before network I/O. The same route-level knob was also the wrong altitude for catalog routes: one provider's models disagree about which levels they accept (deepseek ships `[off, high, max]` beside catalog models with `xhigh`), so a single per-route level could not be set without breaking part of the route, which is why the Models page stopped writing it entirely (#1860) and left `settings.yaml` with no way to align efforts per model. + +Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (`compat.thinkingFormat`, `compat.supportsReasoningEffort`) by recognizing the endpoint URL, and a private gateway's URL says nothing — a DeepSeek-dialect gateway was spoken to in the OpenAI dialect with no configuration that could correct it. And the only way to touch one catalog model was the `models` list, which *replaces* the served catalog: narrowing `gpt-5`'s levels meant restating all thirty-eight openai models or silently dropping thirty-seven. + +## Decision + +`PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, thinking cannot be turned off; declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. + +`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so the pi-ai upgrade that adds a format (0.84 added `baseten`) fails compilation until the new member is classified. + +`modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model). + +## Alternatives considered + +- **Pass `reasoning` + `thinkingLevelMap` through verbatim** (pi-ai's own radius-config shape). Rejected by the user for operator confusion: the map's `null`-marks-unsupported convention plus the asymmetric absent-key rule mean the config's meaning depends on knowledge of pi-ai internals; the chosen shape makes the key set itself the offer. +- **A bare level list** (`reasoningEfforts: [off, high]`). Cannot express wire renames, and the catalog's own maps prove renames are real: 66 of 1230 installed map entries are non-identity (`off→none`, `minimal→low`, `low→LOW`, `high→default`). +- **`{}` as the disable spelling.** Unimplementable: schemastery materializes an absent dict as `{}`, so every model without the field would have been force-disabled. +- **Folding this into the route-level `reasoning` knob.** That knob is a *default selection*, not a capability set; it stays, and a declared model's efforts now bound what it can select. + +## Consequences + +- The composer's effort pane works for hand-declared models with zero UI change — `resolveModelInfo` reports declared levels through the same seam catalog metadata uses (pinned by the `declared-reasoning` web scenario). +- #1860's deferred gap — a route-level effort a model cannot take failing its requests — now has an operator remedy: align the model's `reasoningEfforts` or drop the route default. +- There is deliberately no spelling for returning one map key or compat field to "whatever the catalog said": the declaration is the whole offer, so keeping a catalog value means restating it. The README documents this. +- `verify-package-invariants` is untouched: the feature adds configuration resolution, no new events or mutable runtime relations. diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md new file mode 100644 index 0000000000..47b34dfd27 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md @@ -0,0 +1,33 @@ +# Agent Note: llm-pi-ai 的按模型推理声明 + +Status: implemented + +[English](2026-08-08-pi-ai-per-model-reasoning-declarations.md) | 中文 + +## 问题 + +手工声明的 pi-ai 路由,其模型物化出来就带着 `reasoning: false`,于是 `getSupportedThinkingLevels` 短路成 `["off"]`:输入框不为它们提供档位选择器,而路由级的 `reasoning` 默认值——当时 profile 仅有的推理旋钮——让发往这类模型的每个请求都在网络 I/O 之前以 `UNSUPPORTED_REASONING_EFFORT` 失败。同一个路由级旋钮对 catalog 路由来说也放错了层级:同一提供方下各模型接受的档位并不一致(deepseek 自带 `[off, high, max]`,旁边就是带 `xhigh` 的 catalog 模型),单个路由级档位怎么设都会弄坏路由的一部分——这正是模型页彻底停写它的原因(#1860),而 `settings.yaml` 也因此没有了任何按模型对齐档位的办法。 + +两个相邻的缺口让问题雪上加霜。pi-ai 靠识别端点 URL 来决定推理的*协议方言*(`compat.thinkingFormat`、`compat.supportsReasoningEffort`),而私有网关的 URL 什么也说明不了——说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且没有任何配置能更正它。另外,想动单个 catalog 模型,唯一的手段是 `models` 列表,而它会*替换*所服务的 catalog:收窄 `gpt-5` 的档位,意味着要么重述全部三十八个 openai 模型,要么静默丢掉三十七个。 + +## 决策 + +`PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,思考就关不掉;声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 + +`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级(0.84 加入了 `baseten`)会编译失败,直到新成员被归类。 + +`modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。 + +## 曾考虑的替代方案 + +- **把 `reasoning` + `thinkingLevelMap` 原样透传**(pi-ai 自家 radius 配置的形状)。用户以运维人员困惑为由否决:map 用 `null` 标记「不支持」的约定,加上不对称的键缺席规则,意味着这份配置的含义取决于对 pi-ai 内部机制的了解;选定的形状则让键集合本身就是对外提供的全部。 +- **裸档位列表**(`reasoningEfforts: [off, high]`)。表达不了协议侧改名,而 catalog 自己的 map 证明改名真实存在:1230 条已安装 map 条目里有 66 条不是恒等映射(`off→none`、`minimal→low`、`low→LOW`、`high→default`)。 +- **用 `{}` 作为禁用拼写。** 无法实现:schemastery 会把缺席的字典物化成 `{}`,于是每个没写该字段的模型都会被强制禁用。 +- **把这件事并进路由级的 `reasoning` 旋钮。** 那个旋钮是*默认选择*,不是能力集合;它保留下来,而已声明模型的档位如今约束着它能选什么。 + +## 后果 + +- 输入框的档位面板对手工声明的模型直接可用,UI 零改动——`resolveModelInfo` 经 catalog 元数据所走的同一 seam 报告已声明档位(由 `declared-reasoning` web 场景钉住)。 +- #1860 暂缓的缺口——模型接不住的路由级档位会让发往它的请求失败——如今有了运维侧补救:对齐该模型的 `reasoningEfforts`,或去掉路由默认值。 +- 刻意不提供任何把单个 map 键或 compat 字段交还给「catalog 原本怎么说」的拼写:这份声明就是对外提供的全部,要保留某个 catalog 值就得重述它。README 记载了这一点。 +- `verify-package-invariants` 原封未动:该功能新增的是配置解析,没有新事件,也没有可变的运行时关系。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 21f38d18e2..ef3721a764 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -767,6 +767,22 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Installed-catalog customizations by model id: each entry reshapes that + * one model with the same fields a {@link models} entry takes, while the + * rest of the catalog keeps serving untouched. Only meaningful on a catalog + * route with no `models` list — `models` already replaces the catalog, so + * an override beside it, on a route the catalog does not ship, or naming a + * model the catalog does not describe is refused rather than skipped. + */ + modelOverrides?: Record + /** + * Reasoning-dispatch switches for every `openai-completions` model on this + * route; each model's own `compat` overrides per field. What neither sets + * keeps the installed catalog entry's value, then pi-ai's baseURL-derived + * detection. + */ + compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor * the installed catalog sizes (default 262,144). A guess by construction, so @@ -814,12 +830,70 @@ export interface PiAiModelProfile { * default on its own. */ maxTokens?: number + /** + * Selectable reasoning efforts. Absent inherits the installed catalog + * entry's capability (a hand-declared model has none and does not reason); + * `false` declares a non-reasoning model, which is how a profile strips + * reasoning from a catalog model its gateway cannot serve; a non-empty dict + * declares the offered levels and their wire spellings. + */ + reasoningEfforts?: false | PiAiReasoningEfforts + /** Reasoning-dispatch switches for this model, winning over the route's. */ + compat?: PiAiCompatProfile } + +/** + * Customization of one installed catalog model, keyed by its id in the + * route's `modelOverrides` dict — the same fields a `models` entry may set, + * with the id living in the key. Unlike a `models` list, overrides leave the + * rest of the catalog serving untouched, which is what makes "correct one + * model, keep the other thirty-seven" a three-line edit. + */ +export type PiAiModelOverride = Omit + +/** + * Reasoning-dispatch compatibility switches, set on the route (its models' + * default) or per model (winning over the route). Only the switches pi-ai's + * reasoning dispatch reads are offered; the rest of pi-ai's compat surface + * keeps its baseURL-derived auto-detection. pi-ai types both fields only on + * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning + * shape in the protocol itself — so resolution rejects a model-level switch + * anywhere else, while a route-level default skips past models it cannot fit. + */ +export interface PiAiCompatProfile { + /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + thinkingFormat?: PiAiThinkingFormat + /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + supportsReasoningEffort?: boolean +} + +/** + * Selectable reasoning efforts for one model: each key is a level the model + * offers (and selectors show), and its value is the wire spelling dispatch + * sends for it. `off` alone may leave its value empty — "supported, send + * nothing" — because for most providers not thinking is the parameter's + * absence; every other declared level must name a wire value. A level absent + * from the dict is not offered. + */ +export type PiAiReasoningEfforts = Partial> + +/** One reasoning-dispatch wire format a profile may name. */ +export type PiAiThinkingFormat = Exclude + +/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ +type PiThinkingFormat = NonNullable + +/** + * pi-ai thinking formats a profile cannot name: both drive the request through + * `chatTemplateKwargs`, which this configuration does not expose, so offering + * them would hand back a format with nothing to say. + */ +type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' ``` -Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:126`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:148`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 82c2f2781a..a24c06b8c5 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: 5234f5bb03755c11652eb23f3c5d677fa3cddb40 -providers.zh.md: a54819cab8524a6007c335ad70cecd6516bba25b +providers.md: 6f44daf73037f811164f5b22b14a9c39b71d6b1a +providers.zh.md: 6c75d70d485f55ff230f557247ed6be597a8785e diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 5234f5bb03..6f44daf730 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -59,6 +59,16 @@ llm-pi-ai: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the catalog + # keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high + # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -66,11 +76,22 @@ llm-pi-ai: apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + # key = level offered in the picker, value = what goes on the wire; + # only off may leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` A settings section merges over the matching `cordis.yml` configuration **per provider**, so you can override one field of one route and leave the rest as the composition set them. @@ -79,9 +100,15 @@ A profile the adapter could not serve is refused **where it is written**: a hand ## The model catalog -A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit. +A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit — but once you declare the list, every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. -Only the four fields the harness consumes are configurable: `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no consumer, and reasoning is not per-model configurable at all — it rides the installed catalog entry. +Reshaping a few catalog models while keeping the rest is `modelOverrides`' job: it is keyed by catalog model id, takes the same fields a `models` entry does, and leaves the rest of the catalog serving untouched. An override naming a model the catalog does not describe — or set beside a `models` list, or on a custom provider — is refused rather than silently skipped. + +The configurable model fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no consumer and ride the installed entry. + +**Declare reasoning levels per model.** `reasoningEfforts` lists the levels a model offers: each key appears in the composer's effort picker, and its value is what dispatch sends on the wire — `high: high` passes the name through, `max: ultra` renames it for a gateway with its own vocabulary. A level you leave out is not offered. `off` is special: declared without a value, Off appears in the picker and selecting it sends nothing; left out entirely, the model cannot stop thinking. `reasoningEfforts: false` declares a non-reasoning model, which is also how you strip reasoning from a catalog model your gateway cannot serve. Without this field a custom model does not reason and a catalog model keeps its catalog levels. + +**Pick the reasoning dialect.** How a level travels — plain `reasoning_effort`, DeepSeek's `thinking: {type}` plus effort, and so on — is normally guessed from the endpoint URL, and a private gateway's URL says nothing, so a DeepSeek-style gateway would be spoken to in the OpenAI dialect. `compat.thinkingFormat` sets the dialect explicitly, and `compat.supportsReasoningEffort: false` holds the parameter back from an endpoint that rejects it; both work on the route (its models' default) or per model, for `openai-completions` routes only. A model neither the entry nor the catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields: a deployment whose gateway serves smaller models corrects them once. @@ -114,6 +141,7 @@ If the provider a saved default names is later removed, the composer says **Sele - **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. - **`UNKNOWN_MODEL`** — the requested model is not in the route's configured catalog. Add it to `models`, or use an id the catalog already carries. +- **`UNSUPPORTED_REASONING_EFFORT`** — the request asked the model for a level it does not offer. Pick a level the composer lists for that model, or declare the missing one in the model's `reasoningEfforts`. - **`settings-rejected`** — the written profile cannot be served, and the message names the route and model. For a hand-declared route, check that `api`, `baseURL`, and `models` are all present. - **Fetching available models answers 401** — the endpoint refused the interrogation. Check the key; if the base URL points at an Anthropic-style gateway, note that the interrogation reads only the OpenAI-compatible `GET /models`, so enter the models by hand instead. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index a54819cab8..6c75d70d48 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -59,6 +59,16 @@ llm-pi-ai: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the catalog + # keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high + # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -66,11 +76,22 @@ llm-pi-ai: apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + # key = level offered in the picker, value = what goes on the wire; + # only off may leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上,所以你可以只覆盖某个路由的一个字段,其余保持组合里的样子。 @@ -79,9 +100,15 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 ## 模型目录 -`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑。 +`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑——但一旦声明了这份列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。 -可配置的只有 harness 会消费的四个字段:`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态没有消费方,推理能力也不按模型配置——它随内置目录条目走。 +就地重塑目录里的几个模型、保留其余,归 `modelOverrides` 管:它以目录模型 id 为键,接受与 `models` 条目相同的字段,目录的其余部分原样继续服务。覆盖若点名了目录没有描述的模型,或与 `models` 列表并存,或写在自定义提供方上,都会被拒绝,而不是被静默跳过。 + +可配置的模型字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有消费方,随内置目录条目走。 + +**按模型声明推理档位。** `reasoningEfforts` 列出模型提供的档位:每个键都会出现在输入框的档位选择器里,其值是分派在协议中实际发送的内容——`high: high` 原样透传名称,`max: ultra` 则为使用自有词汇的网关改名。没写的档位不会被提供。`off` 比较特殊:声明而不给值,选择器里会出现 Off,选中它时什么也不发送;完全不写,模型就无法停止思考。`reasoningEfforts: false` 声明一个不具备推理能力的模型,这也是从网关服务不了的目录模型上剥除推理的办法。不写这个字段,自定义模型不推理,目录模型保留目录给出的档位。 + +**选定推理方言。** 档位如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加档位,诸如此类——通常靠端点 URL 来猜,而私有网关的 URL 什么也说明不了,于是 DeepSeek 风格的网关只会收到 OpenAI 方言的请求。`compat.thinkingFormat` 用来显式指定方言,`compat.supportsReasoningEffort: false` 则让该参数不再发给拒绝它的端点;两者既可设在路由上(作为其模型的默认值),也可按模型设置,且仅适用于 `openai-completions` 路由。 两处容量都没给出的模型,取路由级兜底 `defaultContextWindow`(262144)与 `defaultMaxTokens`(32768)。这两个数按定义就是猜测,所以它们是路由字段:网关服务的模型更小时改一次即可。 @@ -114,6 +141,7 @@ api-gateway: - **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 - **`UNKNOWN_MODEL`** — 请求的模型不在该路由配置的目录里。把它加进 `models`,或改用目录里已有的 id。 +- **`UNSUPPORTED_REASONING_EFFORT`** — 请求向模型要了一个它不提供的档位。从输入框为该模型列出的档位里挑一个,或把缺的那个声明进该模型的 `reasoningEfforts`。 - **`settings-rejected`** — 写入的 profile 服务不了,错误信息会点名具体的路由和模型。手工声明的路由检查 `api`、`baseURL`、`models` 是否齐全。 - **获取可用模型返回 401** — 端点拒绝了这次探测。检查密钥;若地址指向的是 Anthropic 风格网关,注意探测只读 OpenAI 兼容的 `GET /models`,此时手工填写模型即可。 From 0fb474f67206e87f90ef77968a7c3e240da8038a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:27:25 +0800 Subject: [PATCH 022/100] test(web): cover user-only skill invocation end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy scenario now expects the user-only quadrant in the menu with its marker (riding the description — the hint field is claim-state ghost text, which the menu never renders), and a new skill-user-invoke scenario drives /name args through the composer against the real host: the claim lands skill.invoke, the transcript shows the dedicated card with the collapsed body, and a paced replay answers the injected turn deterministically. --- apps/web/tests/skill-invocation-policy.e2e.ts | 11 +- apps/web/tests/skill-user-invoke.e2e.ts | 145 ++++++++++++++++++ .../skill-invocation-policy/menu.expected.md | 1 + .../skill-user-invoke/ui.expected.md | 31 ++++ packages/client/ui-skill/src/client/index.ts | 5 +- .../ui-skill/tests/browser-plugin.spec.ts | 4 +- 6 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 apps/web/tests/skill-user-invoke.e2e.ts create mode 100644 apps/web/tests/snapshots/skill-user-invoke/ui.expected.md diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 143bc0d4db..54cd15bf94 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -1,5 +1,6 @@ -// Web e2e scenario: the real host filters skill.list to the model-and-user -// intersection before the browser slash source renders candidates. A real +// Web e2e scenario: the real host serves every user-invocable skill to the +// browser slash source — user-only (disable-model-invocation) entries appear +// with their marker while user-disabled quadrants stay hidden. A real // chromium connects a fresh workspace seeded with all four policy quadrants; // no model call is issued, so a stray stream fails loud on the open LLM seam. import { mkdir, writeFile } from 'node:fs/promises' @@ -92,7 +93,7 @@ describe('web e2e: skill invocation policy through the real host', () => { await scaffold?.close() }) - it('renders only the model-and-user intersection in slash candidates', async () => { + it('renders every user-invocable skill and marks the user-only entry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy')) const input = page.locator('textarea').first() await input.fill('/policy') @@ -102,8 +103,10 @@ describe('web e2e: skill invocation policy through the real host', () => { { timeout: 10_000 }, ).toBe(1) + // The user-only quadrant is invocable here — its only entry point — and + // wears the user-only marker; both user-disabled quadrants stay hidden. + expect(await menu.getByRole('option', { name: /policy-user-only user-only · / }).count()).toBe(1) expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0) - expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0) expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts new file mode 100644 index 0000000000..f722472ded --- /dev/null +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -0,0 +1,145 @@ +// Web e2e scenario: a user invokes a disable-model-invocation skill through +// the composer (issue #1470). The entered `/name args` line claims into +// skill.invoke: the real host renders the skill body, injects it as a +// user-role message carrying the skill-invocation source, and starts a turn +// answered by the replay seam. The transcript shows the dedicated invocation +// card (chip + args, body collapsed) and the model's reply. +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-user-invoke', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +const SKILL_NAME = 'user-invoke-demo' +const ARGS_TEXT = 'and confirm the fixture wiring' +const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.' + +async function seedUserOnlySkill(workspaceCwd: string): Promise { + const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), [ + '---', + `name: ${SKILL_NAME}`, + 'description: Prove user-explicit invocation of a model-hidden skill', + 'disable-model-invocation: true', + '---', + '', + 'Reply with the fixture acknowledgement line.', + '', + ].join('\n')) +} + +const REPLAY: ReplayOverrideDoc = [{ + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: REPLY }, + { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }, + { type: 'usage', usage: { inputTokens: 256, outputTokens: 16 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ], +}] + +describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation through the composer', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let replayDir: string + let tripwire: ReturnType + + beforeAll(async () => { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-skill-user-invoke-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + await writeFile(replayOverride, JSON.stringify(REPLAY)) + scaffold = await launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + // Paced replay keeps the timing-derived chrome (TTFT / tok/s) present + // deterministically; instant playback races it in and out of the golden. + paceMs: 10, + }) + await seedUserOnlySkill(scaffold.workspaceCwd) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (replayDir !== undefined) { + await rm(replayDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed') + }) + + it('claims /name args into an injection card and a replayed answer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke')) + const composer = page.locator('textarea:enabled').last() + await composer.waitFor({ timeout: 15_000 }) + + // The menu lists the user-only skill (its only entry point) before enter. + await composer.fill(`/${SKILL_NAME}`) + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await expect.poll( + () => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(), + { timeout: 10_000 }, + ).toBe(1) + + await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) + await composer.press('Enter') + + // The injection card presents the gesture from source metadata: chip plus + // args, with the rendered collapsed behind a disclosure. + const card = page.locator('[data-skill-invocation]') + await card.waitFor({ timeout: 15_000 }) + const chip = card.locator('[data-ref-chip="skill"]') + expect(await chip.textContent()).toBe(`/${SKILL_NAME}`) + expect(await card.textContent()).toContain(ARGS_TEXT) + + const disclosure = card.locator('details') + expect(await disclosure.getAttribute('open')).toBeNull() + await card.locator('summary').click() + const body = card.locator('pre') + await body.waitFor() + expect(await body.textContent()).toContain(``) + expect(await body.textContent()).toContain('Reply with the fixture acknowledgement line.') + expect(await body.textContent()).toContain(ARGS_TEXT) + await card.locator('summary').click() + + // The injection started a turn; the replay seam answers it. + await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md index 11acc39ad0..ca9230b6f1 100644 --- a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md +++ b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md @@ -1,3 +1,4 @@ - listbox "Trigger suggestions": - text: Skills - option "policy-shared Available to both model and user invocation" [selected] + - option "policy-user-only user-only · Available only to user invocation" diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md new file mode 100644 index 0000000000..b96413f89f --- /dev/null +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -0,0 +1,31 @@ +- banner: + - navigation "Session hierarchy": + - button "workspace" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: /user-invoke-demo and confirm the fixture wiring +- group: View injected skill content +- text: {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 7d859bf2fb..3e23cc997b 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -157,8 +157,9 @@ export function apply(ctx: ClientContext): void { .filter(skill => skill.name.startsWith(query)) .map(skill => ({ name: skill.name, - description: skill.description, - ...skill.modelInvocable ? {} : { hint: userOnlyHint() }, + // The user-only marker rides the description (the menu's only + // secondary text); `hint` is the claim-state ghost text, not a badge. + description: skill.modelInvocable ? skill.description : `${userOnlyHint()} · ${skill.description}`, })) }, warm(session) { diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index e38adf7686..0e098a0b30 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -388,7 +388,7 @@ describe('adjudication', () => { }) describe('user-only marking', () => { - it('carries the user-only hint on candidates the model cannot invoke', async () => { + it('prefixes the description of candidates the model cannot invoke', async () => { const rows: SkillRow[] = [ { name: 'shared-skill', description: 'both surfaces', modelInvocable: true }, { name: 'user-only-skill', description: 'user surface only', modelInvocable: false }, @@ -397,7 +397,7 @@ describe('user-only marking', () => { const candidates = await source.candidates(proj('s1'), req('')) expect(candidates).toEqual([ { name: 'shared-skill', description: 'both surfaces' }, - { name: 'user-only-skill', description: 'user surface only', hint: '仅用户' }, + { name: 'user-only-skill', description: '仅用户 · user surface only' }, ]) }) }) From e46b082fee3aa811699ae8d623f32044b3ee029f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:32:27 +0800 Subject: [PATCH 023/100] test(snapshot): derive compaction replay from logs --- docs/config-catalog.md | 2 +- docs/module-graph.md | 9 +- .../compaction.cordis.snapshot.yml | 25 ++++++ .../headless-agent/tests/compaction.e2e.ts | 4 +- .../headless-agent/tests/headless.snapshot.ts | 73 ++++++++++++++++ .../snapshots/compaction-recovery/input.json | 8 ++ .../compaction-recovery/session.jsonl | 32 +++++++ .../stream-json.expected.jsonl | 32 +++++++ packages/support/llm-replay/README.i18n.yaml | 4 +- packages/support/llm-replay/README.md | 10 ++- packages/support/llm-replay/README.zh.md | 10 ++- packages/support/llm-replay/package.json | 2 + packages/support/llm-replay/src/index.ts | 39 +++++++-- .../llm-replay/tests/llm-replay.spec.ts | 87 +++++++++++++++++++ packages/support/llm-replay/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 16 files changed, 317 insertions(+), 26 deletions(-) create mode 100644 examples/headless-agent/compaction.cordis.snapshot.yml create mode 100644 examples/headless-agent/tests/snapshots/compaction-recovery/input.json create mode 100644 examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl create mode 100644 examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl diff --git a/docs/config-catalog.md b/docs/config-catalog.md index feb8aa9d86..f4302984a8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -857,7 +857,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:710`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:731`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/docs/module-graph.md b/docs/module-graph.md index 14a6b71dc1..d963273363 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -422,9 +422,6 @@ flowchart TD pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session - pkg_llm_replay --> pkg_invariants - pkg_llm_replay --> pkg_llm - pkg_llm_replay --> pkg_session pkg_app_boot --> pkg_environment pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths @@ -500,6 +497,10 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection + pkg_llm_replay --> pkg_compact + pkg_llm_replay --> pkg_invariants + pkg_llm_replay --> pkg_llm + pkg_llm_replay --> pkg_session pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -1219,7 +1220,6 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1239,6 +1239,7 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | diff --git a/examples/headless-agent/compaction.cordis.snapshot.yml b/examples/headless-agent/compaction.cordis.snapshot.yml new file mode 100644 index 0000000000..42fc5306ac --- /dev/null +++ b/examples/headless-agent/compaction.cordis.snapshot.yml @@ -0,0 +1,25 @@ +# Keyless context-overflow composition for the assembled compaction snapshot. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.99 + retainTokens: 20 + maxTokens: 32 + compactionRetries: 1 + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + models: + - id: deepseek-v4-flash + contextWindow: 128000 diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index 07f239a73e..6fe6f4055b 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -11,8 +11,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' * Key-gated smoke for mid-session compaction. It verifies the compact event * pair, replacement of older surface nodes, and a final answer after compaction. */ -// FIXME(compaction-snapshot): this is the only full compaction coverage because -// replay cannot serve the summarizer's unlogged model call. +// The keyless headless snapshot pins deterministic overflow recovery; this test +// remains the independent live-provider smoke for organic pressure and summary quality. let workdir: string | undefined let ctx: Context | undefined diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 121bbf75ed..f9cb46111a 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -29,6 +29,10 @@ const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const retryScenarioDir = join(snapshotsDir, 'provider-retry') const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) +const compactionScenarioDir = join(snapshotsDir, 'compaction-recovery') +const compactionSessionFixture = join(compactionScenarioDir, 'session.jsonl') +const compactionStreamExpected = join(compactionScenarioDir, 'stream-json.expected.jsonl') +const compactionConfigPath = fileURLToPath(new URL('../compaction.cordis.snapshot.yml', import.meta.url)) const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) // Same keyless composition as the missing-credential scenario: the endpoint is @@ -227,6 +231,75 @@ describe('headless stream-json snapshots', () => { expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('recovers from context overflow through an assembled compaction', async () => { + const prompt = await scenarioPrompt(compactionScenarioDir, 'compaction-recovery') + let expectedSession = await readFile(compactionSessionFixture, 'utf8') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'compaction recovery headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-compaction-recovery-', + binScript, + configPath: compactionConfigPath, + binArgs: ['--config', compactionConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: compactionSessionFixture, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(1) + const actual = logs[0] + if (actual === undefined) throw new Error('compaction snapshot did not persist its session') + const records = parseJsonl(actual.content) + const types = records.map(record => record.type) + expect(types.filter(type => type === 'compact/start')).toHaveLength(1) + expect(types.filter(type => type === 'compact/summary')).toHaveLength(1) + expect(types.filter(type => type === 'compact/end')).toHaveLength(1) + const start = types.indexOf('compact/start') + const summary = types.indexOf('compact/summary') + const replacement = records.findIndex((record) => { + if (record.type !== 'user/message') return false + const surfaceOp = record.surfaceOp as JsonObject | undefined + return surfaceOp?.op === 'replace' + }) + const end = types.indexOf('compact/end') + expect(start).toBeLessThan(summary) + expect(summary).toBeLessThan(replacement) + expect(replacement).toBeLessThan(end) + const summaryRecord = records[summary] + const summaryData = summaryRecord?.data as JsonObject | undefined + expect(summaryData?.shadowedSeqs).toEqual(expect.arrayContaining([expect.any(Number)])) + const final = [...records].reverse().find(record => record.type === 'assistant/message') + expect(JSON.stringify(final)).toContain('COMPACTION RECOVERED') + + const actualContext = contextFromLogs([actual.content]) + if (refreshing) { + const harvested: HarvestedLog = { + id: String(actual.header.id), + createdAt: Number(actual.header.createdAt), + content: actual.content, + } + const replacements = refreshFixtureReplacements([harvested], [expectedSession]) + expectedSession = tokenizeSessionFixtureCwd( + stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext), + ) + await writeFile(compactionSessionFixture, expectedSession) + } + const expectedContext = contextFromLogs([expectedSession]) + expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) + .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext))) + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(compactionStreamExpected, normalized) + expect(normalized).toBe(await readFile(compactionStreamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs actionable missing-credential guidance through the one-shot app', async () => { const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl') let runCwd = '' diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/input.json b/examples/headless-agent/tests/snapshots/compaction-recovery/input.json new file mode 100644 index 0000000000..3ccad96b83 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED." + } + ] +} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl new file mode 100644 index 0000000000..855e66ac14 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl @@ -0,0 +1,32 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"}]}} +{"type":"turn/start","seq":1,"time":1786123401614,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786123401614,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786123401667,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1786123401667,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1786123401669,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e54f73a8-572a-40ee-b908-8a8a27b83bf8"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1786123401680,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} +{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"b4a6504e-f39d-40b0-b51a-b11fbd60b135"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1786123401700,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} +{"type":"compact/start","seq":19,"time":1786123401715,"data":{"turn":1}} +{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} +{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"6d2afb13-a37b-48d6-9ea5-fc8734127377"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"compact/end","seq":22,"time":1786123401725,"data":{"turn":1}} +{"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} +{"type":"assistant/chunk","seq":25,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} +{"type":"assistant/chunk","seq":26,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":27,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"346742d0-50e3-4594-b53c-f26c7da82c56"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":1786123401730,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":30,"time":1786123401730,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl new file mode 100644 index 0000000000..4d798adc37 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl @@ -0,0 +1,32 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","sessionId":"{{sessionId}}","output":"COMPACTION RECOVERED","usage":{"inputTokens":44,"outputTokens":10}} diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index a4729b2e69..3f3e349a84 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/llm-replay/README.md -README.md: ee062d0c2804905f33f1ff476d12bb6dd57666e5 -README.zh.md: ab3420d9500a6ca77f04a2ad96095f8883aeb874 +README.md: 46d391970f320708914d11f0868cbbc5361ae196 +README.zh.md: a67b078a1396968dc3ddecb0e616a832c4faaf3a diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index ee062d0c28..46d391970f 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -8,7 +8,9 @@ Its consumers are the ACP and headless `stream-json` snapshot suites plus the We ## How the fixture works -The fixture IS the persisted session log (`/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model call per loop step). Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` events and the line-0 session header. +The fixture IS the persisted session log (`/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each agent-loop `stream()` call's chunk sequence. A successful compaction summarizer is logged differently: when `compact/summary` carries its complete `rawOutput`, replay reconstructs a canonical successful stream at that event's position using one `block-start`/`block-end` pair per block, the recorded usage when present, and a terminal `stop`. Exact provider delta partitioning is not part of the durable compaction result. A summary without `rawOutput` does not imply an LLM call because template and remote summarizers may produce it without the local adapter. + +Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` and `compact/summary` events plus the line-0 session header. Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. @@ -57,7 +59,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s - `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars. - `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. - `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing). -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn ordinary loop chunks and complete compaction outputs in a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived assistant group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. - Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`. ## Plugin export shape @@ -74,5 +76,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently (or a compaction summarize call landing mid-run) would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`). -- **Only chunk-producing calls are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs. +- **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`). +- **Only ordinary loop chunks and completed compaction outputs are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs. diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index ab3420d950..a67b078a13 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -8,7 +8,9 @@ ## fixture 的工作方式 -fixture 就是持久化的会话日志(`/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 `stream()` 调用的分片序列(每个循环步骤调用一次模型)。因此,录制就是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成;该插件本身不录制。fixture 的 `request/header` 内容可能被标记化为 `{{system}}`/`{{tools}}`(harness 会在一个场景中固定该内容,并清除其余场景中的内容);回放不受影响,因为派生过程只读取 `assistant/chunk` 事件和第 0 行的会话 header。 +fixture 就是持久化的会话日志(`/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 agent-loop `stream()` 调用的分片序列。压缩(compaction)摘要器成功时,日志记录方式有所不同:当 `compact/summary` 携带完整的 `rawOutput` 时,回放会在该事件的位置重建一条规范成功流,其中每个块各使用一对 `block-start`/`block-end`,带上已记录的 usage(如有),并以 `stop` 终止。提供方增量的精确切分不属于持久压缩结果。不带 `rawOutput` 的摘要并不意味着发生了 LLM 调用,因为模板摘要器和远程摘要器可能不经本地适配器生成该摘要。 + +因此,录制就是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成;该插件本身不录制。fixture 的 `request/header` 内容可能被标记化为 `{{system}}`/`{{tools}}`(harness 会在一个场景中固定该内容,并清除其余场景中的内容);回放不受影响,因为派生过程只读取 `assistant/chunk` 和 `compact/summary` 事件以及第 0 行的会话 header。 有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。 @@ -57,7 +59,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as - `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。 - `loadSessionScripts(config)`:解析场景中有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。 - `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。 -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。 +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志中的普通 loop 分片和完整压缩输出转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生的 assistant 分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。 - 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 ## 插件导出形态 @@ -74,5 +76,5 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as ## 已知限制与暂缓事项 -- **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut(或运行中发生的上下文压缩(context compaction)摘要调用)会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。 -- **只有会产生分片的调用才能派生**:在产生分片前直接抛出异常或取消/挂起的场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。 +- **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut 会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。 +- **只有普通 loop 分片和已完成的压缩输出才能派生**:在产生分片前直接抛出异常或取消/挂起的场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。 diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index 708e84b57a..af5e2929f3 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -25,12 +25,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index ae62843492..8733a4296c 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -1,14 +1,16 @@ /** * Keyless snapshot-test LLM replay. It derives one model-call script per - * recorded session from `assistant/chunk` events and binds fresh live sessions - * to parent/child scripts by first-call order. Throw and hang cases require an - * explicit override because a session log cannot reconstruct them alone. + * recorded session from `assistant/chunk` events and durable compaction + * summaries, then binds fresh live sessions to parent/child scripts by + * first-call order. Throw and hang cases require an explicit override because + * a session log cannot reconstruct them alone. * @module @deepseek-ai/dsh-llm-replay */ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { delimiter as pathDelimiter } from 'node:path' import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-compact' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { @@ -24,8 +26,9 @@ import { LlmAdapter, LlmError, assertNever, resolveRetryPolicy } from '@deepseek /** * One recorded model call. `throw` may replay prefix chunks before failing; - * `hang` models cancellation. Only ordinary chunk entries derive from JSONL; - * the other variants come from an override sidecar. + * `hang` models cancellation. Chunk entries derive from ordinary model streams + * and complete compaction outputs in JSONL; the other variants come from an + * override sidecar. */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } @@ -174,10 +177,12 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe * Reconstruct the per-`stream()` replay script from a recorded session log. * * Splits `assistant/chunk` events at every `finish`, using turn and step changes - * to detect an unterminated prior call. A missing terminator means the live - * stream threw, so derivation rejects and the scenario must provide an explicit - * override. Multiple calls may share one turn and step when the loop retries. - * @param events - the recorded session's events; only `assistant/chunk` is consulted. + * to detect an unterminated prior call. A complete `compact/summary.rawOutput` + * becomes a canonical successful stream at the summary's log position. A + * missing assistant terminator means the live stream threw, so derivation + * rejects and the scenario must provide an explicit override. Multiple calls + * may share one turn and step when the loop retries. + * @param events - the recorded session's events. * @returns one `chunks` entry per recorded model call, in call order. */ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { @@ -195,6 +200,22 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { script.push({ kind: 'chunks', chunks }) } for (const event of events) { + if (event.type === 'compact/summary') { + close(currentKey, current) + currentKey = undefined + current = [] + if (event.data.rawOutput !== undefined) { + const chunks: StreamChunk[] = [] + for (const [index, block] of event.data.rawOutput.entries()) { + chunks.push({ type: 'block-start', index, blockType: block.type }) + chunks.push({ type: 'block-end', index, block }) + } + if (event.data.usage !== undefined) chunks.push({ type: 'usage', usage: event.data.usage }) + chunks.push({ type: 'finish', reason: { kind: 'stop' } }) + script.push({ kind: 'chunks', chunks }) + } + continue + } if (event.type !== 'assistant/chunk') continue const { turn, step, chunk } = event.data const key = `${turn}/${step}` diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 0b7d87ad13..9483a4c4f6 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -178,6 +178,93 @@ describe('deriveReplayScript', () => { expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: errChunks }]) }) + it('inserts compact/summary output between the calls surrounding it', () => { + const overflow: StreamChunk[] = [ + { type: 'finish', reason: { kind: 'error', failure: { message: 'too large', code: 'CONTEXT_WINDOW_EXCEEDED' } } }, + ] + const block = { type: 'text' as const, text: 'durable checkpoint' } + const rawOutput = [block] + const usage = { inputTokens: 9, outputTokens: 2 } + const summaryChunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block }, + { type: 'usage', usage }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + let seq = 1 + const events: SessionEvent[] = [ + ...overflow.map(chunk => chunkEvent(seq++, 1, 2, chunk)), + { type: 'compact/start', seq: seq++, time: 0, data: { turn: 1 } }, + { + type: 'compact/summary', + seq: seq++, + time: 0, + data: { + summary: rawOutput, + rawOutput, + shadowedRange: { start: 1, end: 1 }, + shadowedSeqs: [1], + shadowedTokenCount: 20, + provider: 'mock', + model: 'mock', + usage, + }, + }, + ...TEXT_CHUNKS.map(chunk => chunkEvent(seq++, 1, 2, chunk)), + ] + + expect(deriveReplayScript(events)).toEqual([ + { kind: 'chunks', chunks: overflow }, + { kind: 'chunks', chunks: summaryChunks }, + { kind: 'chunks', chunks: TEXT_CHUNKS }, + ]) + }) + + it('does not infer an LLM call from compact/summary without raw output', () => { + const event: SessionEvent<'compact/summary'> = { + type: 'compact/summary', + seq: 1, + time: 0, + data: { + summary: [{ type: 'text', text: 'template result' }], + shadowedRange: { start: 1, end: 1 }, + shadowedSeqs: [1], + shadowedTokenCount: 20, + provider: 'template', + model: 'template', + }, + } + + expect(deriveReplayScript([event])).toEqual([]) + }) + + it('derives a compact/summary stream when usage is unavailable', () => { + const block = { type: 'text' as const, text: 'summary without usage' } + const event: SessionEvent<'compact/summary'> = { + type: 'compact/summary', + seq: 1, + time: 0, + data: { + summary: [block], + rawOutput: [block], + shadowedRange: { start: 1, end: 1 }, + shadowedSeqs: [1], + shadowedTokenCount: 20, + provider: 'mock', + model: 'mock', + }, + } + + expect(deriveReplayScript([event])).toEqual([{ + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block }, + { type: 'finish', reason: { kind: 'stop' } }, + ], + }]) + }) + it('throws on a group that lacks a terminal finish chunk (a thrown stream)', () => { // A thrown stream(): prefix chunks logged, then turn/end (error reason), NO finish. const events: SessionEvent[] = [ diff --git a/packages/support/llm-replay/tsconfig.json b/packages/support/llm-replay/tsconfig.json index 673ee51547..b8dc74e792 100644 --- a/packages/support/llm-replay/tsconfig.json +++ b/packages/support/llm-replay/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../compact/compact" + }, { "path": "../../llm/llm" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3a3d5ecca..078f775ecf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5959,6 +5959,9 @@ importers: packages/support/llm-replay: devDependencies: + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../invariants From 74ba0b532edd355a974541d1e1663a5f7c77f939 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:33:56 +0800 Subject: [PATCH 024/100] chore: sync the lockfile for the dsh-skill llm dependency --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd4ff1ea14..045d76aeab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5155,6 +5155,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From 95c01e6e61195c3c993fb306ffea8a5627e1224b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:40:14 +0800 Subject: [PATCH 025/100] cleanup: reject private issue shorthand --- .../2026-08-06-api-key-format-validation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-06-api-key-format-validation.md | 6 ++---- .../2026-08-06-api-key-format-validation.zh.md | 6 ++---- scripts/verify-public-repository-links.spec.ts | 7 +++++-- scripts/verify-public-repository-links.ts | 12 ++++++++---- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index e1c3ac3ef8..f4f105e124 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: e9ca76ede06080f2b868f6436998d163e642adbc -2026-08-06-api-key-format-validation.zh.md: 5666a884d4c9478291072375681d8d3526b2632a +2026-08-06-api-key-format-validation.md: 2174cb466c6af72f15005ce1ba3dec8100de6f2f +2026-08-06-api-key-format-validation.zh.md: f9b7d6518fedc42e5264c46beaa1a78619139c58 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index e9ca76ede0..2174cb466c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -14,13 +14,11 @@ Pasting a key containing an emoji, CJK text, or a full-width punctuation mark in Whitespace passed every check. `ProviderEditor` tested `keyDraft.length`, so a key of three spaces was stored and then authenticated as `Bearer` plus blanks. Neither adapter checked a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. -Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. - ## Decision One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. -This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. +This single predicate covers every reported input: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the failures share one definition rather than two coincidentally related fixes. A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=[^=]` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen — and requiring a non-`=` character after the separator keeps base64 padding clear of it too. It reports the same format failure as an illegal character rather than its own message: the reader's next move is identical either way, so a separate line would name a cause without changing what to do. @@ -76,7 +74,7 @@ The client cannot import any of this: client packages reference only client pack **Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. -**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. +**Probing the provider at save time to prove the key works.** It would close the original complaint — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 5666a884d4..f9b7d6518f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -14,13 +14,11 @@ Status: implemented 空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。两个适配器都不检查来自凭据或环境的 Key——而那正是 Models 页写入的路径,也就是用户真正走的路径。 -来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 - ## Decision 一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 -这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 +这一个断言覆盖了所有已报告的输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以这些故障收敛于同一个定义,而不是两个恰好相关的修复。 第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=[^=]` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 `=` 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。 @@ -76,7 +74,7 @@ Status: implemented **让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 -**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 +**在保存时探测 provider 以证明 Key 可用。** 它能关掉最初报告的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 ## Consequences diff --git a/scripts/verify-public-repository-links.spec.ts b/scripts/verify-public-repository-links.spec.ts index b05dcb65d1..615bfa68e2 100644 --- a/scripts/verify-public-repository-links.spec.ts +++ b/scripts/verify-public-repository-links.spec.ts @@ -2,15 +2,18 @@ import { describe, expect, it } from 'vitest' import { findInternalRepositoryReferences } from './verify-public-repository-links.ts' describe('public repository link policy', () => { - it('rejects the internal remote and accepts the public home', () => { - const internalRepository = ['deepseek-harness', 'deepseek-harness'].join('/') + it('rejects internal repository references and accepts the public home', () => { + const internalOwner = ['deepseek', 'harness'].join('-') + const internalRepository = [internalOwner, internalOwner].join('/') const source = [ 'https://github.com/deepseek-ai/deepseek-harness-sdk', `https://github.com/${internalRepository}/issues/1`, + `${internalOwner}#2`, ].join('\n') expect(findInternalRepositoryReferences('subject.md', source)).toEqual([ { file: 'subject.md', line: 2 }, + { file: 'subject.md', line: 3 }, ]) }) }) diff --git a/scripts/verify-public-repository-links.ts b/scripts/verify-public-repository-links.ts index dc8d2b3b35..a57628e00c 100644 --- a/scripts/verify-public-repository-links.ts +++ b/scripts/verify-public-repository-links.ts @@ -1,4 +1,4 @@ -/** Reject tracked files that expose the internal repository remote. */ +/** Reject tracked files that expose the internal repository identity. */ import { execFileSync } from 'node:child_process' import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs' @@ -6,7 +6,9 @@ import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' const root = resolve(import.meta.dirname, '..') -const internalRepository = ['deepseek-harness', 'deepseek-harness'].join('/') +const internalOwner = ['deepseek', 'harness'].join('-') +const internalRepository = [internalOwner, internalOwner].join('/') +const internalIssueShorthand = `${internalOwner}#` /** One tracked reference to the internal repository. */ export interface InternalRepositoryReference { @@ -25,7 +27,9 @@ export interface InternalRepositoryReference { export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] { const references: InternalRepositoryReference[] = [] for (const [index, line] of source.split('\n').entries()) { - if (line.includes(internalRepository)) references.push({ file, line: index + 1 }) + if (line.includes(internalRepository) || line.includes(internalIssueShorthand)) { + references.push({ file, line: index + 1 }) + } } return references } @@ -55,7 +59,7 @@ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(re if (isMain) { const references = scanRepository(root) if (references.length === 0) { - console.log('verify-public-repository-links: tracked files expose no internal repository remote.') + console.log('verify-public-repository-links: tracked files expose no internal repository identity.') } else { console.error('verify-public-repository-links: internal repository references found:') for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`) From eba4df1e86c4b2e94792178e5f35b3330814b3c8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:50:37 +0800 Subject: [PATCH 026/100] cleanup: skip redundant Issue lifecycle ready runs --- ...-driven-issue-lifecycle-triggers.i18n.yaml | 6 ++++ ...-review-driven-issue-lifecycle-triggers.md | 31 +++++++++++++++++++ ...view-driven-issue-lifecycle-triggers.zh.md | 31 +++++++++++++++++++ .github/workflows/issue-lifecycle.yml | 1 - scripts/ci-workflow.spec.ts | 28 +++++++++++++++++ 5 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md create mode 100644 .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml new file mode 100644 index 0000000000..a82d54640c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md +2026-08-08-review-driven-issue-lifecycle-triggers.md: 8a2d48ee23da4c20bb832ae0109e2ea9912dac83 +2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 004739ff471815b0fe12e111eba0ec7aaaef9507 diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md new file mode 100644 index 0000000000..8a2d48ee23 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md @@ -0,0 +1,31 @@ +# Agent Note: Review-driven Issue lifecycle triggers + +Status: implemented + +English | [中文](2026-08-08-review-driven-issue-lifecycle-triggers.zh.md) + +## Problem + +The Issue lifecycle workflow reads the current pull request after each subscribed repository event and projects resolving Issues forward to `In progress` or `In review`. A resolving draft already reaches `In progress` from its `opened` event. Changing that draft to ready creates no new lifecycle outcome until a reviewer is requested or submits a review, yet subscribing to `ready_for_review` launches another hosted job and creates another GitHub App token. + +Draft-to-ready automation commonly submits a review moments later. In that sequence the ready job cannot advance the Issue, while the review job is still required to observe the `In review` phase. + +## Decision + +[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) does not subscribe to `pull_request.ready_for_review`. It retains `pull_request.review_requested` and `pull_request_review.submitted`, so either a requested reviewer or a submitted review can advance a resolving Issue to `In review`. The handler continues to fetch the live pull request instead of deriving phase from the triggering payload. + +[Issue policy](../../../../.github/workflows/issue-policy.yml) still subscribes to `ready_for_review`. That workflow owns the required check when a human pull request enters review; removing a lifecycle trigger does not weaken policy enforcement. + +The workflow test parses both files and pins this split. The lifecycle policy tests separately pin that draft and open resolving pull requests reach `In progress`, while a review request or submitted review reaches `In review`. + +## Alternatives considered + +- **Keep both events and cancel an in-progress run** - rejected because concurrency can discard a pending run but cannot combine two webhook payloads into one execution. Cancelling the earlier mutation also makes correctness depend on arrival order, while a completed ready job still consumes the full runner setup. +- **Remove the submitted-review event** - rejected because a review may arrive without an explicit review request. In that path `pull_request_review.submitted` is the only repository event that exposes the transition to `In review`. +- **Delay every pull request event behind a debounce dispatcher** - rejected because another queue or scheduled workflow adds latency and control-plane state to eliminate a trigger that carries no lifecycle information. + +## Consequences + +A draft becoming ready no longer launches Issue lifecycle work. The resolving Issue remains `In progress` from an earlier pull request event until a review is requested or submitted, at which point one review-driven run can advance it to `In review`. The required Issue policy check still runs at the ready boundary. + +If a future lifecycle phase depends on ready status itself, that change must restore the trigger and update the workflow test and this decision. Until then, omitting `ready_for_review` saves one hosted run from the common ready-then-review sequence without dropping a status transition. diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md new file mode 100644 index 0000000000..004739ff47 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 由评审驱动的 Issue 生命周期触发器 + +Status: implemented + +[English](2026-08-08-review-driven-issue-lifecycle-triggers.md) | 中文 + +## 问题 + +Issue 生命周期工作流会在每个已订阅的仓库事件发生后读取当前 PR(Pull Request),并将解决型 Issue 的状态向前推进到 `In progress` 或 `In review`。解决型草稿 PR 已通过其 `opened` 事件进入 `In progress`。在请求评审人或评审人提交评审之前,把该草稿转为可评审状态不会产生新的生命周期结果;但订阅 `ready_for_review` 仍会启动另一个托管作业,并创建另一个 GitHub App token。 + +草稿转为可评审状态的自动化通常会在片刻后提交评审。在这一事件序列中,转为可评审状态的作业无法推进 Issue,而要观察到 `In review` 阶段,仍必须运行评审作业。 + +## 决策 + +[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)不订阅 `pull_request.ready_for_review`。它保留 `pull_request.review_requested` 和 `pull_request_review.submitted`,因此无论是请求评审人还是提交评审,都可以将解决型 Issue 推进至 `In review`。处理程序仍会获取实时 PR,而不是根据触发事件的载荷推导阶段。 + +[Issue 政策](../../../../.github/workflows/issue-policy.yml)仍订阅 `ready_for_review`。该工作流负责在由人类发起的 PR 进入评审时执行必需检查;移除生命周期触发器不会削弱政策执行。 + +工作流测试会解析这两个文件,并固定这种划分。生命周期政策测试另行固定以下行为:草稿及开放状态的解决型 PR 会进入 `In progress`,评审请求或已提交评审则会使其进入 `In review`。 + +## 考虑过的替代方案 + +- **保留两个事件并取消正在进行的工作流运行**:不予采纳,因为并发控制可以丢弃待处理的工作流运行,却无法把两个 webhook 载荷合并为一次执行。取消较早的状态变更操作也会使正确性依赖事件到达顺序;而已经完成的转为可评审状态作业仍会产生完整的运行器初始化开销。 +- **移除已提交评审事件**:不予采纳,因为评审可能在没有明确评审请求的情况下直接提交。在这条路径中,`pull_request_review.submitted` 是唯一能让系统观察到进入 `In review` 这一状态转换的仓库事件。 +- **让每个 PR 事件都先经过防抖分派器再处理**:不予采纳,因为新增一条队列或一个定时工作流会引入延迟和控制平面状态,只为消除一个不携带生命周期信息的触发器。 + +## 后果 + +草稿转为可评审状态后,不再启动 Issue 生命周期工作。解决型 Issue 会保持在更早的 PR 事件所设定的 `In progress`,直到请求或提交评审;届时,一次由评审驱动的工作流运行即可将其推进至 `In review`。必需的 Issue 政策检查仍会在转为可评审状态的边界运行。 + +如果未来某个生命周期阶段依赖可评审状态本身,相关变更必须恢复该触发器,并更新工作流测试和本决策。在此之前,省略 `ready_for_review` 可使常见的先转为可评审状态、再提交评审这一序列少启动一次托管工作流运行,而不会遗漏状态转换。 diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 4dc6869e27..7a25b5223d 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -21,7 +21,6 @@ on: - reopened - labeled - unlabeled - - ready_for_review - review_requested pull_request_review: types: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 7febf0049a..baeac7a8c0 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -28,6 +28,34 @@ describe('CI workflow', () => { }) }) +describe('Issue lifecycle workflow', () => { + it('uses review signals instead of rerunning when a draft becomes ready', () => { + const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') + const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request') + const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review') + const policy = loadWorkflow('.github/workflows/issue-policy.yml') + const policyPullRequest = workflowEvent(policy, 'pull_request') + + expect(lifecyclePullRequest.types).not.toContain('ready_for_review') + expect(lifecyclePullRequest.types).toContain('review_requested') + expect(lifecycleReview.types).toContain('submitted') + expect(policyPullRequest.types).toContain('ready_for_review') + }) +}) + +function loadWorkflow(path: string): Record { + const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8')) + if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`) + return workflow +} + +function workflowEvent(workflow: Record, event: string): Record { + if (!isRecord(workflow.on) || !isRecord(workflow.on[event])) { + throw new TypeError(`workflow must define the ${event} event`) + } + return workflow.on[event] +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } From db146f0eba2c4b26987beff5a7a2e243a2c2ebe8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:52:42 +0800 Subject: [PATCH 027/100] refactor(host): share the turn-start route refusal between prompt and skill.invoke turnAgentFor owns the addressed-agent resolution and the model-unavailable refusal both turn-starting methods repeat; the duplication gate flagged the copied block. --- packages/host/apiproxy/src/api-proxy.ts | 66 +++++++++++++------------ 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 6384a4d408..3970a801a3 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1248,6 +1248,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return llm === undefined || llm.listProviders().some(entry => entry.id === provider) } + /** + * Resolve the addressed agent for a turn-starting method and refuse when no + * adapter serves its current route: a route nothing serves cannot start a + * turn, and letting it try spends the whole pre-step path to fail inside + * the adapter with a message about registration. Refusing here names the + * model the session is pointed at while the draft is still in the composer. + * This is the enforcement boundary shared by `session.prompt` and + * `skill.invoke`: a client that disables its input is an affordance, and + * both methods stay callable regardless. + */ + async function turnAgentFor( + request: RpcRequest, sessionId: SessionId, + ): Promise<{ agent: Agent } | { refused: RpcResponse }> { + const found = await agentFor(sessionId) + if ('error' in found) return { refused: err(request, found.error) } + const agent = found.agent + const target = targetFor(agent).current + if (!routeServed(target.provider)) { + return { + refused: err(request, { + code: 'model-unavailable', + message: `no adapter serves provider "${target.provider}"; select a model for this session`, + details: { provider: target.provider, model: target.model }, + }), + } + } + return { agent } + } + /** Missing-service report shared by the settings domain (skills-domain stance). */ function settingsAbsent(): RpcError { return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} } @@ -1784,23 +1813,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async prompt(request) { const { sessionId, mode, content } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const agent = found.agent - // A route no adapter serves cannot start a turn, and letting it try - // spends the whole pre-step path to fail inside the adapter with a - // message about registration. Refusing here names the model the - // session is pointed at while the draft is still in the composer. - // This is the enforcement boundary: a client that disables its input - // is an affordance, and this method stays callable regardless. - const target = targetFor(agent).current - if (!routeServed(target.provider)) { - return err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${target.provider}"; select a model for this session`, - details: { provider: target.provider, model: target.model }, - }) - } + const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) + if ('refused' in resolved) return resolved.refused + const agent = resolved.agent // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { @@ -2377,20 +2392,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async invoke(request) { const { sessionId, name, text } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const agent = found.agent - // Same turn-start refusal boundary as sessions.prompt: injection - // starts a turn, so a route no adapter serves is refused while the - // composer still shows the draft. - const target = targetFor(agent).current - if (!routeServed(target.provider)) { - return err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${target.provider}"; select a model for this session`, - details: { provider: target.provider, model: target.model }, - }) - } + const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) + if ('refused' in resolved) return resolved.refused + const agent = resolved.agent const skillRegistry = ctx.get('skills') if (skillRegistry === undefined) { return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) From 3584d8e08804aae652dcaa43ed63052b6cddc50c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:52:43 +0800 Subject: [PATCH 028/100] docs(skill): document the user-explicit invocation path Bilingual README updates for the four touched packages (ui-skill's claim flow and deterministic-injection model experience, the apiproxy skills domain, the shared renderSkillContent seam export, the catalog stitch sentence), the implemented Agent Note triplet recording the decision and its peer-product evidence, and the regenerated catalogs/graphs. --- ...8-user-explicit-skill-invocation.i18n.yaml | 6 ++++ ...26-08-08-user-explicit-skill-invocation.md | 36 +++++++++++++++++++ ...08-08-user-explicit-skill-invocation.zh.md | 36 +++++++++++++++++++ docs/config-catalog.md | 4 +-- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/client/ui-skill/README.i18n.yaml | 4 +-- packages/client/ui-skill/README.md | 15 ++++---- packages/client/ui-skill/README.zh.md | 15 ++++---- packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/skill/skill/README.i18n.yaml | 4 +-- packages/skill/skill/README.md | 4 +++ packages/skill/skill/README.zh.md | 4 +++ packages/skill/tool-skill/README.i18n.yaml | 4 +-- packages/skill/tool-skill/README.md | 3 +- packages/skill/tool-skill/README.zh.md | 3 +- 19 files changed, 121 insertions(+), 31 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml new file mode 100644 index 0000000000..ed9de78dbb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +2026-08-08-user-explicit-skill-invocation.md: 9249ee5c9c712e9c6aa827e97178f352728ed927 +2026-08-08-user-explicit-skill-invocation.zh.md: f15975c3b13fbf76e036fcece30253e78e7b417d diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md new file mode 100644 index 0000000000..9249ee5c9c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -0,0 +1,36 @@ +# Agent Note: User-explicit skill invocation over skill.invoke + +Status: implemented + +English | [中文](2026-08-08-user-explicit-skill-invocation.zh.md) + +## Problem + +A `disable-model-invocation: true` skill is user-only by design: it never enters the model-facing catalog and the `skill` tool refuses to load it. Its only legitimate entry point is an explicit user gesture — yet the web client had none. `skill.list` filtered to the model-and-user intersection (hiding user-only skills from the menu), an entered `/name` line rode into the default prompt sink as plain text, and the model it reached was forbidden to load the skill — so it degraded to `read`-ing the SKILL.md file or ignoring the gesture (issue #1470). Even for ordinary skills, the decision-21 plain-text reference made user invocation a collaboration cue the model could ignore, not a guarantee. + +## Decision + +User-explicit invocation is a deterministic host-side injection, uniform for every user-invocable skill: + +- `skill.invoke { sessionId, name, text? }` (host apiproxy) enforces user-invocation policy at the operation boundary (`skill-not-found` / `skill-not-invocable`), renders the skill with the shared `renderSkillContent`, appends the optional trailing text after a blank line, and injects the whole as one user-role message carrying the new `skill-invocation` `MessageSource` kind (`{ name, args? }`) before starting a turn through the same route-served gate as `session.prompt`. +- `renderSkillContent` moved from `dsh-tool-skill` to the `dsh-skill` seam: the `skill` tool result and the injection share one verbatim `` shape, and the catalog text gained the seam rule — an inline-injected skill must be followed, not re-loaded through the tool. +- `skill.list` serves every user-invocable skill and carries `modelInvocable`, so the browser menu lists user-only skills with a marker (description prefix — the `hint` field is claim-state ghost text the menu never renders). +- ui-skill claims a menu pick or an entered `/name [args]` into the invoke transaction (`matchEnter` strong-waits the catalog; unknown names stay plain prompts). The unreached legacy `name` reference codec is removed. +- The transcript materializes the injection as a dedicated `skill-invocation` node from source metadata (never re-parsed from the body) and renders a right-aligned bubble: `/name` chip, trailing text, and the injected block collapsed behind a disclosure. + +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous: user-explicit triggering is programmatic injection as a user-role message with zero model participation on every product, prompt-guided tool loading exists only on the model-autonomous track, and the disable-model-invocation equivalents gate only the model-side surfaces. Kimi's origin-metadata rendering and the Claude Code/Kimi no-reload prompt rule translate directly onto `MessageSource` and the catalog sentence. + +## Alternatives considered + +- **`agent.inject()` context injection** — no peer precedent; the gesture is a user turn, not an environment notice, and context-row presentation, compaction, and attribution all mismatch. Rejected. +- **A host `/skill ` command** (command registry, plan-mode precedent) — two-token UX, no name completion, and user-only skills stay undiscoverable in the menu; the per-cwd skill catalog also fits the static command registry poorly. Rejected. +- **Client-side expansion** (fetch body, splice into the prompt) — authorization becomes bypassable client courtesy, the log loses the invocation semantics, and Codex deleted its equivalent mechanism (custom prompts) in favor of core injection. Rejected. +- **Host prompt-pipeline scanning for `/name`** (Codex `$name` core mentions) — duplicates the adjudication layer and risks swallowing literal slashes in prose; the claim path already covers the need. Rejected. +- **Per-injection preamble line** (Kimi's `User activated the skill …`) — dropped in favor of a one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result. + +## Consequences + +- Decision 21's plain-text reference path is superseded at submission: the draft still carries plain text and lexicon-derived chip visuals, but submit claims into a deterministic injection instead of shipping the literal and hoping. The model-autonomous track (catalog + `skill` tool) is unchanged. +- Every user-invocable skill invocation now costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. +- The `skill-invocation` source rides `user/message`, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers. +- TUI and ACP can adopt `skill.invoke` later for the same semantics; until then the TUI's client-side expansion remains its own path. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md new file mode 100644 index 0000000000..f15975c3b1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 经 skill.invoke 的用户显式 skill 调用 + +Status: implemented + +[English](2026-08-08-user-explicit-skill-invocation.md) | 中文 + +## 问题 + +`disable-model-invocation: true` 的 skill(技能)在设计上就是仅限用户的:它绝不进入面向模型的目录,`skill` 工具也拒绝加载它。它唯一正当的入口是一次显式的用户手势——而 web 客户端此前没有这个入口。`skill.list` 过滤到模型与用户的交集(把仅限用户的 skill 挡在菜单之外),回车提交的 `/name` 一行以纯文本落入默认提示词 sink,而这行文本到达的模型又被禁止加载该 skill——于是退化为模型去 `read` 那份 SKILL.md 文件,或者干脆无视这次手势(issue #1470)。即使对普通 skill,决策 21 的纯文本引用也让用户调用只是模型可以忽略的协作线索,而不是保证。 + +## 决策 + +用户显式调用是一次确定性的宿主侧注入,对每一个用户可调用的 skill 一致: + +- `skill.invoke { sessionId, name, text? }`(宿主 apiproxy)在操作边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),用共享的 `renderSkillContent` 渲染该 skill,在一个空行之后追加可选的尾随文本,并把整体作为一条携带新增 `skill-invocation` `MessageSource` kind(`{ name, args? }`)的 user 角色消息注入,随后经由与 `session.prompt` 相同的「路由是否有适配器在服务」闸门开启一个轮次。 +- `renderSkillContent` 从 `dsh-tool-skill` 移入 `dsh-skill` seam:`skill` 工具结果与注入共享同一份逐字一致的 `` 形态,目录文本则新增了这条 seam 规则——已内联注入的 skill 必须被遵循,而不是再经工具重新加载。 +- `skill.list` 提供每一个用户可调用的 skill 并携带 `modelInvocable`,因此浏览器菜单会带标记地列出仅限用户的 skill(描述前缀——`hint` 字段是认领态的 ghost text,菜单从不渲染它)。 +- ui-skill 把菜单 pick 或回车提交的 `/name [args]` 认领进 invoke 事务(`matchEnter` 强等目录;未知名称保持为普通提示词)。已不可达的旧 `name` 引用 codec 被移除。 +- transcript(文本记录)依据来源元数据把这次注入物化为专用的 `skill-invocation` 节点(绝不从正文重新解析),并渲染为一个右对齐气泡:`/name` chip、尾随文本,以及收在 disclosure 之后的注入块。 + +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)结论一致:在每个产品上,用户显式触发都是以 user 角色消息做程序化注入、模型零参与;提示词引导的工具加载只存在于模型自主轨道上;disable-model-invocation 的对应物只把关模型侧表层。Kimi 的来源元数据渲染与 Claude Code/Kimi 的禁止重载提示词规则,可直接平移到 `MessageSource` 与目录那句话上。 + +## 考虑过的替代方案 + +- **`agent.inject()` 上下文注入**——没有同类产品先例;这次手势是一个用户轮次,不是环境通知,而且上下文行呈现、压缩(compaction)与归属全都不匹配。否决。 +- **宿主 `/skill ` 命令**(命令注册表,plan 模式先例)——两 token 的 UX、没有名称补全、仅限用户的 skill 在菜单里仍不可发现;按 cwd 的 skill 目录也与静态命令注册表格格不入。否决。 +- **客户端展开**(拉取正文、拼进提示词)——授权沦为可被绕过的客户端善意,日志失去调用语义,而且 Codex 已删除其等价机制(custom prompts)转向核心注入。否决。 +- **宿主提示词流水线扫描 `/name`**(Codex 的 `$name` core mentions)——重复了裁决层,还有吞掉普通行文中字面斜杠的风险;认领路径已经覆盖了这一需求。否决。 +- **每次注入一条前导语**(Kimi 的 `User activated the skill …`)——弃用,改为一次性的目录句子:同样的上下文、只支付一次,且注入块与工具结果保持逐字节一致。 + +## 后果 + +- 决策 21 的纯文本引用路径在提交处被取代:草稿仍承载纯文本与 lexicon 派生的 chip 视觉,但提交会认领进一次确定性注入,而不是把字面文本发出去再碰运气。模型自主轨道(目录 + `skill` 工具)不变。 +- 每一次用户可调用 skill 的调用现在都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。 +- `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 +- TUI 与 ACP 之后可以为同样的语义采用 `skill.invoke`;在那之前,TUI 的客户端展开仍是它自己的路径。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 21f38d18e2..9f1bf08f9d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1471,7 +1471,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:170`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:261`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -2063,7 +2063,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:58`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:59`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ad9797262..55952b3591 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -677,7 +677,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:188`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:279`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4a73dc06ad..4abd00c1fd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,7 +1946,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promisename
` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink. +Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. + +A menu pick or an entered `/name [args]` line claims the composer into an args-tolerant `skill.invoke` transaction (`matchEnter` strong-waits the catalog; an unknown name answers undefined and stays a plain prompt). Submit trims the args, keeps blank args off the wire, and folds an RPC refusal into the composer's error outcome; the host renders the skill body and injects it as a user message before starting the turn, so invocation is deterministic for every user-invocable skill. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. Draft chip visuals still derive from the `lexicon` scan; the legacy `name` reference codec is gone (decision 21 removal cut) and `matchSpace` stays unimplemented — menu and enter own the skill flows. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. @@ -14,23 +16,22 @@ The browser plugin also registers a keyed `skill` toolview in `conversation.chat ## Model Experience -### Skill reference text in the user prompt +### User-explicit skill invocation #### What the model sees -A picked candidate lands the literal `/name ` in the draft (decision 21: plain text, no `` tag); the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side expansion. The association with the actual skill is model-side and non-deterministic: the session prefix already carries the skill catalog (rendered by `dsh-tool-skill`), and the reference's name matching a catalog entry is what invites the model to load it. +A claimed invocation never ships the `/name` literal. The host (`skill.invoke`) renders the canonical `` block — the same `renderSkillContent` output the `skill` tool returns — appends the user's trailing text after a blank line, and injects the whole as one user-role message carrying the `skill-invocation` source, immediately starting a turn. Loading is deterministic: the model receives the full body without being asked to call the `skill` tool, and the catalog (rendered by `dsh-tool-skill`) tells it not to re-load an inline-injected skill. #### Token effect -Conditional and tiny: only a pick (or hand-typing the same text) adds the reference's characters to that one user message. Menu browsing and the candidate fetch add zero model tokens. +One invocation adds the rendered skill body plus the trailing text to that turn's user message — the same cost as the model loading the skill through the tool, paid unconditionally instead of at the model's discretion. Menu browsing and the candidate fetch add zero model tokens. #### KV Cache effect -Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens. +Append-only: the injected message lands after the reusable history prefix. This package never edits earlier request tokens. ## Known Limitations and Deferred Work - **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it. -- **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change. -- **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog. +- **Enter waits on the catalog once** — `matchEnter` strong-waits the session's first catalog fetch before answering, so an enter racing a cold cache resolves against the settled catalog rather than silently missing. A menu opened before the prewarm settles still shows no skill candidates for that keystroke. - **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item). diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 6eb6cbd3ae..3bbbc90186 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 流水线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `name`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。 +skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 + +菜单 pick 或回车提交的一行 `/name [args]` 会把 composer 认领进一个容忍参数的 `skill.invoke` 事务(`matchEnter` 强等目录;未知名称应答 undefined,保持为普通提示词)。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 @@ -14,23 +16,22 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## 模型体验 -### 用户提示词中的 skill 引用文本 +### 用户显式 skill 调用 #### 模型看到的内容 -被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21:纯文本,无 `` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且具有非确定性:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。 +被认领的调用绝不会把字面文本 `/name` 发出去。宿主(`skill.invoke`)渲染规范的 `` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——在一个空行之后追加用户的尾随文本,并把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,随即开启一个轮次。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录(由 `dsh-tool-skill` 渲染)也会告诉它不要重新加载已内联注入的 skill。 #### Token 影响 -有条件且极小:只有 pick(或手动键入相同文本)会把引用的字符加进那一条用户消息。浏览菜单和拉取候选不会增加任何模型 token。 +一次调用会把渲染后的 skill 正文连同尾随文本加进该轮次的用户消息——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。 #### KV Cache 影响 -仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。 +仅追加:注入的消息落在可复用历史前缀之后。该包绝不改写较早的请求 token。 ## 已知限制与暂缓事项 - **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。 -- **skill 加载具有非确定性**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。 -- **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。 +- **回车对目录只等待一次**:`matchEnter` 在应答之前强等该会话的首次目录拉取,因此与冷缓存竞速的回车会对照已落定的目录解析,而不是静默错过。预热落定之前打开的菜单,在那次击键下仍不会显示 skill 候选。 - **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 0a0131d292..017bd32970 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 7ac7bdc6db2e2abbc60d1a8813e229c21ed39fe7 -README.zh.md: d6ece5caed752cf0cc59cc97017549ec2b1e66cb +README.md: 8d7a24b0b8b897d94ed29d5dc9ed6e9efb250fc6 +README.zh.md: c988b7540ba719d02e50d6da9595353c93766835 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 7ac7bdc6db..8d7a24b0b8 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -46,7 +46,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's invocation path: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point this is. `skill.invoke` is the user-explicit loading RPC: it enforces user-invocation policy at this boundary (`skill-not-found` / `skill-not-invocable`), renders the canonical `` body via the shared `renderSkillContent`, appends the optional trailing `text`, injects the whole as a user-role message carrying the `skill-invocation` source, and starts a turn through the same route-served refusal gate as `session.prompt`. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index d6ece5caed..c988b7540b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -46,7 +46,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的调用路径:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——此处是这类条目唯一的入口。`skill.invoke` 是用户显式加载 RPC:它在此边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),经共享的 `renderSkillContent` 渲染规范的 `` 正文,追加可选的尾随 `text`,把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,并经由与 `session.prompt` 相同的「路由是否有适配器在服务」拒绝闸门开启一个轮次。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index 03d1b13fe8..fe29171cb3 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill/README.md -README.md: f538ae668ccff291be86348627d5547150f460df -README.zh.md: d61a242d01df1e22270c1cb049b922536654bbd6 +README.md: 0c1b2249d8c46ad9ce8097ceeda2bd988c92eb21 +README.zh.md: 8fed350d00433206aecdb32819adc81c82745869 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index f538ae668c..0c1b2249d8 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -37,6 +37,10 @@ This package owns the `ctx.skills` interface. It does not know whether skills co | `{ modelInvocable: false, userInvocable: true }` | excluded | included | | `{ modelInvocable: false, userInvocable: false }` | excluded | excluded | +### Shared model-facing rendering + +`renderSkillContent(skill)` renders one loaded skill as the canonical `` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result, and the host's user-explicit `skill.invoke` injects it as a user message, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, args? }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body. + `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill. ## Provider Contract diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index d61a242d01..8fed350d00 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -37,6 +37,10 @@ | `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 | | `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 | +### 共享的面向模型渲染 + +`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,宿主的用户显式 `skill.invoke` 将其作为用户消息注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind({ name, args? }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。 + `isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。 ## 提供方契约 diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index b57689d742..19fa44c67c 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md -README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626 -README.zh.md: c6b815bef59eb1f14be0892078694f129366d004 +README.md: 5c6e592c670f324eb660dbe1fec168fd77e5b368 +README.zh.md: 202a621b1d4047c7d763de3b98c1a69c8c1ee1f7 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 8e0bff5d1c..5c6e592c67 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence. ##### Skill catalog template @@ -49,6 +49,7 @@ A skill is a reusable set of task-specific instructions. The following skills ar If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. +A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill. ``` diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index c6b815bef5..202a621b1d 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -36,7 +36,7 @@ #### 模型看到的内容 -如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。 +如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板携带同一句话。 ##### Skill 目录模板 @@ -49,6 +49,7 @@ A skill is a reusable set of task-specific instructions. The following skills ar If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. +A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill. ``` From 8982d714cb2bc362af06cd1274afc1acb667c891 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:15:44 +0800 Subject: [PATCH 029/100] fix(snapshot): harden message id retention --- ...table-snapshot-refresh-volatiles.i18n.yaml | 4 +- ...07-27-stable-snapshot-refresh-volatiles.md | 8 +- ...27-stable-snapshot-refresh-volatiles.zh.md | 8 +- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 13 +- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 4 +- packages/support/acp-snapshot/README.zh.md | 4 +- packages/support/acp-snapshot/package.json | 2 + packages/support/acp-snapshot/src/suite.ts | 162 +++++++++----- .../support/acp-snapshot/tests/suite.spec.ts | 199 ++++++++++++------ packages/support/acp-snapshot/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 12 files changed, 283 insertions(+), 131 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml index e322fba1dd..0820c01b7f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md -2026-07-27-stable-snapshot-refresh-volatiles.md: a0613357c698934f91598bdf53da983b1dd53f08 -2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 303a4d6a4cc9f2d45448359c0e48677228a0c1f9 +2026-07-27-stable-snapshot-refresh-volatiles.md: c3eeeca01a7820b5f410bd895de998e944e58eb2 +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 388b67c67052074fa7423eae294e00b4122b2fe8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md index a0613357c6..c3eeeca01a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md @@ -12,9 +12,9 @@ Message identity needs a weaker structural precondition than aligned records: an ## Decision -Before record or refresh writes session fixtures, the shared snapshot support fingerprints every complete surface message with its top-level `id` removed and groups occurrences across all parent/child logs. It reuses an existing UUID only when one fingerprint resolves to exactly one fresh ID and one existing ID, then applies that mapping to every fresh log. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. ACP, JSON-RPC, and Web recorders pass fixture-ready logs through the same helper before writing. +Before record or refresh writes session fixtures, the shared snapshot support passes fixture-ready logs to one structural message-ID owner. It recognizes surface carriers through the session package's authoritative surface-type predicate and the correlated queued copies in `agent/inbox/spliced`, fingerprints every complete message with its top-level `id` removed, and records every ID-to-fingerprint edge across all parent/child logs. It reuses an existing UUID only when both its ID and fingerprint have degree one in the fresh and existing graphs, then rewrites only validated message `id` fields in those carriers. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. ACP, JSON-RPC, and Web recorders run this pass after header scrubbing and cwd tokenization, so fixture spellings rather than raw host paths determine identity. -Refresh write-back uses `normalizeSessionLog` as its volatile-value authority for aligned leaves. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. +Refresh write-back uses `normalizeSessionLog` as its volatile-value authority for aligned leaves. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements are limited to fresh-run session IDs, cwd values, and spill paths. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. Complete message IDs in surface or inbox carriers are excluded from this path so positional reuse and structural reuse cannot assign the same committed UUID independently. Before reuse, the complete logical-record layout must align, apart from the existing packed-chunk and inserted-title equivalences. Normalized-equivalent changed strings form a log-wide bijection: one fresh string maps to exactly one existing string and vice versa, so repeated IDs remain correlated across records. An unexplained record mismatch or conflicting mapping disables normalized string reuse for that log. @@ -30,6 +30,6 @@ Object fields align by key. Array elements align only when all corresponding arr ## Consequences -Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout, regardless of whether ACP, JSON-RPC, or Web owns the recording. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, and non-unique message fingerprints use fresh values rather than risk reusing misaligned data. +Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout, regardless of whether ACP, JSON-RPC, or Web owns the recording. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, malformed messages, and any message graph with a non-unique ID or fingerprint use fresh values rather than risk reusing misaligned data. -Focused unit coverage pins scenario-wide parent/child message correlation, unrelated event insertion, record write-back, new/changed/ambiguous messages, recursive object/array behavior, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. +Focused unit coverage pins all authoritative surface-message shapes, durable inbox/surface correlation, scenario-wide parent/child correlation, cwd-bearing fixture-ready matching, unrelated event insertion, malformed-message isolation, both-axis graph ambiguity, single-owner write-back, recursive object/array behavior, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md index 303a4d6a4c..388b67c670 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -12,9 +12,9 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 决策 -在录制或刷新写入会话 fixture 前,共享快照支持层会移除每条完整 surface 消息的顶层 `id` 并计算指纹,同时将所有父级/子级日志中的出现项分组。仅当一个指纹恰好对应一个本次生成的 ID 和一个现有 ID 时,才会复用现有 UUID,随后将该映射应用到每份本次生成的日志。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。ACP、JSON-RPC 和 Web 录制器都会先让可写入 fixture 的日志经过同一个辅助函数,再执行写入。 +在录制或刷新写入会话 fixture 前,共享快照支持层会将可写入 fixture 的日志交给一个负责结构化处理消息 ID 的组件。该组件通过会话包的权威 surface 类型谓词识别 surface 载体,并识别 `agent/inbox/spliced` 中与这些载体关联的已排队消息副本;随后移除每条完整消息的顶层 `id` 并计算指纹,同时记录所有父级/子级日志中每条 ID 与指纹之间的关联边。仅当该 ID 与指纹在本次生成图和现有图中的度均为 1 时,才会复用现有 UUID,随后仅改写这些载体中通过验证的消息 `id` 字段。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。ACP、JSON-RPC 和 Web 录制器会在擦除 header 并对 cwd 进行 token 化后执行这一步,因此消息身份取决于 fixture 中的写法,而非宿主机原始路径。 -刷新写回以 `normalizeSessionLog` 作为已对齐叶值的易变值判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture 头部上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 +刷新写回以 `normalizeSessionLog` 作为已对齐叶值的易变值判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture 头部上下文归一化 fixture 记录;字面量替换仅限于本次运行生成的会话 ID、cwd 值和 spill 路径。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。surface 或 inbox 载体中的完整消息 ID 不参与这一路径,以免按位置复用与结构复用各自独立分配同一个已提交 UUID。 复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用归一化字符串复用。 @@ -30,6 +30,6 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 后果 -录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID,无论该录制由 ACP、JSON-RPC 还是 Web 负责。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化,或消息指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 +录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID,无论该录制由 ACP、JSON-RPC 还是 Web 负责。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化、消息格式错误,或消息图中的 ID 或指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。 -聚焦的单元测试固定了场景范围内的父级/子级消息关联、无关事件插入、录制写回、新增/发生变化/有歧义的消息、递归处理对象与数组的行为、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 +聚焦的单元测试固定了会话包权威谓词识别的所有 surface 消息形态、持久 inbox/surface 关联、场景范围内的父级/子级消息关联、带 cwd 的可写入 fixture 消息匹配、无关事件插入、格式错误消息隔离、消息图在 ID 与指纹两条轴上的歧义、由单一处理方负责的写回、递归处理对象与数组的行为、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 1f4335402e..4a29d6eb6a 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -350,15 +350,18 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { content: log.content, })) const replacements = refreshFixtureReplacements(harvested, expectedContents) - expectedContents = await Promise.all(ordered.map(async (log, index) => { + const refreshed = ordered.map((log, index) => { const existing = expectedContents[index] - const file = files[index] - if (existing === undefined || file === undefined) throw new Error(`no fixture for persisted log ${index}`) - const stable = scrubRequestHeaders(tokenizeSessionFixtureCwd( + if (existing === undefined) throw new Error(`no fixture for persisted log ${index}`) + return scrubRequestHeaders(tokenizeSessionFixtureCwd( stabilizeRefreshLog(log.content, existing, replacements, actualContext), )) + }) + expectedContents = stabilizeFixtureMessageIds(refreshed, expectedContents) + await Promise.all(expectedContents.map(async (stable, index) => { + const file = files[index] + if (file === undefined) throw new Error(`no fixture for persisted log ${index}`) await writeFile(file, stable) - return stable })) } diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index bed064e909..25b54630b2 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md -README.md: 9d142dc964e60f9508b6c137525eb916cdbd969f -README.zh.md: 2e88e7c5bb8acb0cd99a35b5fe0fbfc15d6101e3 +README.md: 0b935ef60c33fd24660d8ecf2497f5506157c724 +README.zh.md: 91be3c97bcb67ce10c61513113f683e741bc762f diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 9d142dc964..0b935ef60c 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -8,8 +8,8 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. -- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → one canonical `{{cwd}}`, including an already-tokenized macOS `/private` alias; authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)), and `stabilizeFixtureMessageIds` (committed UUIDs carried into unchanged, unambiguous messages across any recorder's fixture-ready parent/child logs). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, noncanonical macOS-prefixed cwd tokens, unscrubbed JSONL headers, and malformed pinning headers. Before record or refresh writes fixtures, an unchanged complete message retains its committed UUID when its identity-free value resolves to exactly one fresh ID and one existing ID across the scenario's parent/child logs; new, changed, and ambiguous messages keep fresh UUIDs. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → one canonical `{{cwd}}`, including an already-tokenized macOS `/private` alias; authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)), and `stabilizeFixtureMessageIds` (committed UUIDs carried into unchanged, mutually unique messages by structurally rewriting only complete surface and durable-inbox message ID fields across any recorder's fixture-ready parent/child logs). +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, noncanonical macOS-prefixed cwd tokens, unscrubbed JSONL headers, and malformed pinning headers. Before record or refresh writes fixtures, an unchanged complete message retains its committed UUID only when both its ID and identity-free fingerprint are unique across the scenario's fixture-ready parent/child logs; the session package's authoritative surface-type predicate selects surface carriers, correlated `agent/inbox/spliced` copies join the same mapping, and only validated `id` fields in those carriers are rewritten. New, changed, malformed, and graph-ambiguous messages keep fresh UUIDs. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; complete message IDs in surface or inbox carriers are excluded because the later structural pass owns them, ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 2e88e7c5bb..91be3c97bc 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -8,8 +8,8 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 -- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名,包括已 token 化的 macOS `/private` 别名 → 单一规范 `{{cwd}}`;手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)、`scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))和 `stabilizeFixtureMessageIds`(针对任意录制器已准备写入 fixture 的父级/子级日志,将已提交 UUID 带入未变化且无歧义的消息)。 -- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、带非规范 macOS 前缀的 cwd token、未擦除的 JSONL header,以及格式错误的 pin header。在录制或刷新写入 fixture 前,如果一条未变化的完整消息去除身份后的值在场景的父级/子级日志中恰好对应一个本次生成的 ID 和一个现有 ID,它就会保留已提交的 UUID;新增、发生变化和有歧义的消息则保留本次生成的 UUID。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 +- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名,包括已 token 化的 macOS `/private` 别名 → 单一规范 `{{cwd}}`;手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)、`scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))和 `stabilizeFixtureMessageIds`(针对任意录制器已准备写入 fixture 的父级/子级日志,通过结构化方式仅改写 surface 和持久 inbox 中完整消息的 ID 字段,将已提交 UUID 带入未变化且双向唯一匹配的消息)。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、带非规范 macOS 前缀的 cwd token、未擦除的 JSONL header,以及格式错误的 pin header。在录制或刷新写入 fixture 前,仅当一条未变化完整消息的 ID 及其去除身份后的指纹在场景可写入 fixture 的父级/子级日志中均唯一时,该消息才会保留已提交的 UUID;会话包的权威 surface 类型谓词负责选择 surface 载体,与其关联的 `agent/inbox/spliced` 副本也纳入同一映射,且仅改写这些载体中通过验证的 `id` 字段。新增、发生变化、格式错误以及图关系存在歧义的消息保留本次生成的 UUID。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用归一化后等价的叶值;surface 或 inbox 载体中的完整消息 ID 不参与此路径,因为后续结构化处理负责这些 ID;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index c231591103..b504cbe50d 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -31,10 +31,12 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 993aee254f..c0cfc56e24 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -20,6 +20,7 @@ import { readFile, readdir, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' +import { isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' import { describe, expect, it } from 'vitest' import { type AgentUnderTest, type HarvestedLog, type InputScript, runScenario } from './harness.ts' import { @@ -512,11 +513,11 @@ export function headerChangeCount(rawLog: string): number { .length } -/** A literal string replacement used to carry an existing fixture value into fresh write-back. */ +/** A literal replacement from a fresh replay-run volatile to its existing fixture value. */ export interface FixtureReplacement { - /** The fresh run's value to replace. */ + /** The fresh replay run's volatile value. */ from: string - /** The existing fixture value to keep. */ + /** The existing fixture value retained during write-back. */ to: string } @@ -526,24 +527,51 @@ function parseJsonlRecords(text: string): Record[] { .map(line => JSON.parse(line) as Record) } +/** Narrow one parsed value to the complete identified-message shape retained by fixtures. */ +function completeMessage(value: unknown): Record | undefined { + if ( + !isRecord(value) + || typeof value.id !== 'string' + || !UUID_RE.test(value.id) + || typeof value.role !== 'string' + || !Array.isArray(value.content) + || !isRecord(value.source) + ) return undefined + return value +} + /** Return the complete identified message carried by one surface event. */ -function eventMessage(record: Record): Record | undefined { +function surfaceEventMessage(record: Record): Record | undefined { + const type = record.type + if (typeof type !== 'string' || !isSurfaceEligibleType(type)) return undefined const data = record.data if (!isRecord(data)) return undefined - const message = record.type === 'user/message' - ? data - : record.type === 'assistant/message' || record.type === 'tool/result' || record.type === 'steering/message' - ? data.message - : undefined - if ( - !isRecord(message) - || typeof message.id !== 'string' - || !UUID_RE.test(message.id) - || typeof message.role !== 'string' - || !Array.isArray(message.content) - || !isRecord(message.source) - ) return undefined - return message + let message: unknown + switch (type) { + case 'user/message': + message = data + break + case 'assistant/message': + case 'tool/result': + message = data.message + break + /* v8 ignore next -- the authoritative predicate must fail loud when a new surface shape lands. */ + default: throw new Error(`acp-snapshot: unsupported surface event type "${type}"`) + } + return completeMessage(message) +} + +/** Return complete message identities structurally owned by one durable record. */ +function recordMessages(record: Record): Record[] { + const surfaceMessage = surfaceEventMessage(record) + if (surfaceMessage !== undefined) return [surfaceMessage] + if (record.type !== 'agent/inbox/spliced' || !isRecord(record.data) || !Array.isArray(record.data.inserted)) { + return [] + } + return record.data.inserted.flatMap((value) => { + const message = completeMessage(value) + return message === undefined ? [] : [message] + }) } /** Serialize parsed JSON by value rather than insertion order. */ @@ -555,42 +583,48 @@ function canonicalJson(value: unknown): string { return JSON.stringify(value) } -/** Index each unambiguous identity-free message value by its sole message id. */ -function uniqueMessageIds(logs: readonly string[]): Map { - const fingerprintsById = new Map() +/** Index identity-free message values whose ID and fingerprint are mutually unique. */ +function uniqueMessageIds(logs: readonly string[]): Map { + const fingerprintsById = new Map>() + const idsByFingerprint = new Map>() for (const log of logs) { for (const record of parseJsonlRecords(log)) { - const message = eventMessage(record) - if (message === undefined) continue - const { id, ...withoutId } = message - const messageId = id as string - const fingerprint = canonicalJson(withoutId) - if (!fingerprintsById.has(messageId)) fingerprintsById.set(messageId, fingerprint) - else if (fingerprintsById.get(messageId) !== fingerprint) fingerprintsById.set(messageId, undefined) + for (const message of recordMessages(record)) { + const { id, ...withoutId } = message + const messageId = id as string + const fingerprint = canonicalJson(withoutId) + const fingerprints = fingerprintsById.get(messageId) + if (fingerprints === undefined) fingerprintsById.set(messageId, new Set([fingerprint])) + else fingerprints.add(fingerprint) + const ids = idsByFingerprint.get(fingerprint) + if (ids === undefined) idsByFingerprint.set(fingerprint, new Set([messageId])) + else ids.add(messageId) + } } } - const idsByFingerprint = new Map() - for (const [id, fingerprint] of fingerprintsById) { - if (fingerprint === undefined) continue - if (!idsByFingerprint.has(fingerprint)) idsByFingerprint.set(fingerprint, id) - else idsByFingerprint.set(fingerprint, undefined) + const unique = new Map() + for (const [id, fingerprints] of fingerprintsById) { + if (fingerprints.size !== 1) continue + const fingerprint = fingerprints.values().next().value as string + if (idsByFingerprint.get(fingerprint)?.size !== 1) continue + unique.set(fingerprint, id) } - return idsByFingerprint + return unique } /** * Match unchanged complete messages across a scenario's fresh and existing logs. - * New, changed, repeated, or otherwise ambiguous messages keep their fresh ids. + * New, changed, duplicate-content, or otherwise ambiguous messages keep their fresh ids. */ -function fixtureMessageIdReplacements(logs: readonly string[], fixtures: readonly string[]): FixtureReplacement[] { +function fixtureMessageIdReplacements(logs: readonly string[], fixtures: readonly string[]): Map { const freshIds = uniqueMessageIds(logs) const existingIds = uniqueMessageIds(fixtures) - const replacements: FixtureReplacement[] = [] + const replacements = new Map() for (const [fingerprint, fresh] of freshIds) { const existing = existingIds.get(fingerprint) - if (fresh === undefined || existing === undefined || fresh === existing) continue - replacements.push({ from: fresh, to: existing }) + if (existing === undefined || fresh === existing) continue + replacements.set(fresh, existing) } return replacements } @@ -602,6 +636,22 @@ function applyFixtureReplacements(content: string, replacements: readonly Fixtur return stable } +/** Rewrite only validated durable-message ID fields, leaving every other occurrence untouched. */ +function applyFixtureMessageIds(content: string, replacements: ReadonlyMap): string { + return content.split('\n').map((line) => { + if (line.trim().length === 0) return line + const record = JSON.parse(line) as Record + let changed = false + for (const message of recordMessages(record)) { + const replacement = replacements.get(message.id as string) + if (replacement === undefined) continue + message.id = replacement + changed = true + } + return changed ? JSON.stringify(record) : line + }).join('\n') +} + /** * Carry committed UUIDs into unchanged, unambiguous messages in fresh session fixtures. * @@ -611,7 +661,7 @@ function applyFixtureReplacements(content: string, replacements: readonly Fixtur */ export function stabilizeFixtureMessageIds(logs: readonly string[], fixtures: readonly string[]): string[] { const replacements = fixtureMessageIdReplacements(logs, fixtures) - return logs.map(log => applyFixtureReplacements(log, replacements)) + return logs.map(log => applyFixtureMessageIds(log, replacements)) } /** One packed row's member times, or `undefined` for an ordinary record. */ @@ -659,15 +709,15 @@ export function unknownToolCallIds(rawLog: string): string[] { } /** - * Build refresh write-back replacements: scenario-wide unchanged message ids, - * plus per-log session ids, cwd values, and spill paths. + * Build refresh write-back replacements for per-log session ids, cwd values, + * and spill paths. Durable message ids have a later structural owner. * * @param logs The freshly harvested logs, in fixture order. * @param fixtures The existing fixture contents, in matching order. * @returns Literal replacements from fresh values to the fixture's existing values. */ export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] { - const replacements = fixtureMessageIdReplacements(logs.map(log => log.content), fixtures) + const replacements: FixtureReplacement[] = [] for (let i = 0; i < logs.length; i++) { const fresh = parseJsonlRecords((logs[i] as HarvestedLog).content)[0] const existing = parseJsonlRecords(fixtures[i] ?? '')[0] @@ -818,6 +868,7 @@ function collectNormalizedStringMappings( existing: unknown, normalizedFresh: unknown, normalizedExisting: unknown, + excludedStrings: ReadonlySet, forward: Map, reverse: Map, ): boolean { @@ -837,6 +888,7 @@ function collectNormalizedStringMappings( existing[index], normalizedFresh[index], normalizedExisting[index], + excludedStrings, forward, reverse, )) @@ -856,6 +908,7 @@ function collectNormalizedStringMappings( existing[key], normalizedFresh[key], normalizedExisting[key], + excludedStrings, forward, reverse, )) @@ -866,6 +919,8 @@ function collectNormalizedStringMappings( || typeof normalizedFresh !== 'string' || normalizedFresh !== normalizedExisting || fresh === existing + || excludedStrings.has(fresh) + || excludedStrings.has(existing) ) return true const freshKey = JSON.stringify([normalizedFresh, fresh]) const existingKey = JSON.stringify([normalizedFresh, existing]) @@ -891,6 +946,10 @@ function normalizedStringMappings( freshContext: NormalizeContext, existingContext: NormalizeContext, ): Map | undefined { + const excludedStrings = new Set() + for (const record of [...freshRecords, ...existingRecords]) { + for (const message of recordMessages(record)) excludedStrings.add(message.id as string) + } const forward = new Map() const reverse = new Map() let existingIndex = 0 @@ -912,6 +971,7 @@ function normalizedStringMappings( existingRecord, normalizedRefreshRecord(freshRecords[recordIndex] as Record, freshContext), normalizedRefreshRecord(existingRecord, existingContext), + excludedStrings, forward, reverse, )) return undefined @@ -924,11 +984,13 @@ function normalizedStringMappings( /** * Rewrite a fresh replay-produced log so repeated refreshes do not churn * volatile fixture fields. Meaningful event payloads come from `fresh`; the - * existing fixture lends normalized-equivalent values, including ids, paths, + * existing fixture lends normalized-equivalent values, including non-message ids, paths, * creation/event times, spill locators, and hook durations, only when the * complete record layout aligns and volatile strings form a consistent - * bijection. Ambiguous layouts or mappings keep fresh strings. Packed timing - * envelopes expand for alignment, so packing does not shift later records; + * bijection. Complete durable-message ids are excluded because the later + * fixture-ready structural pass owns them. Ambiguous layouts or mappings + * keep fresh strings. Packed timing envelopes expand for alignment, so + * packing does not shift later records; * fresh semantic values and fragment arrays remain authoritative. * * @param fresh The newly harvested session JSONL. @@ -1158,17 +1220,15 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const refreshReplacements = REFRESHING ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) : [] - const outputFixtures = REFRESHING + const freshFixtures = REFRESHING ? result.sessionLogs.map((log, index) => scrub(portableFixture(stabilizeRefreshLog( log.content, existingFixtures[index] as string, refreshReplacements, ctx, )))) - : stabilizeFixtureMessageIds( - result.sessionLogs.map(log => scrub(portableFixture(log.content))), - existingFixtures, - ) + : result.sessionLogs.map(log => scrub(portableFixture(log.content))) + const outputFixtures = stabilizeFixtureMessageIds(freshFixtures, existingFixtures) await Promise.all(outputFixtures.map((fixture, index) => writeFile(join(dir, outputFixtureFiles[index] as string), fixture))) if (RECORDING) { diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index a915590b5d..3cfd1c0f0a 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -7,6 +7,7 @@ import { afterAll, describe, expect, it } from 'vitest' import { defineAcpSnapshotSuite, stabilizeFixtureMessageIds, + tokenizeSessionFixtureCwd, type HarvestedLog, type Scenario, } from '../src/index.ts' @@ -679,6 +680,109 @@ describe('stabilizeFixtureMessageIds', () => { } }) + it('rewrites only complete messages carried by surface events or durable inbox splices', () => { + const ids = { + freshUser: '11111111-1111-4111-8111-111111111111', + oldUser: '22222222-2222-4222-8222-222222222222', + freshAssistant: '33333333-3333-4333-8333-333333333333', + oldAssistant: '44444444-4444-4444-8444-444444444444', + freshTool: '55555555-5555-4555-8555-555555555555', + oldTool: '66666666-6666-4666-8666-666666666666', + oldMalformed: '77777777-7777-4777-8777-777777777777', + } as const + const message = (id: string, role: string, text: string): Record => ({ + id, + role, + content: [{ type: 'text', text }], + source: { kind: role === 'user' ? 'user' : 'model' }, + }) + const log = (userId: string, assistantId: string, toolId: string, malformedId: string): string => [ + JSON.stringify({ type: 'session', id: 'same', cwd: '{{cwd}}' }), + JSON.stringify({ + type: 'agent/inbox/spliced', + data: { + inserted: [ + message(userId, 'user', 'user'), + { ...message(userId, 'user', 'malformed inbox'), source: null }, + ], + }, + }), + JSON.stringify({ type: 'user/message', data: message(userId, 'user', 'user') }), + JSON.stringify({ type: 'assistant/message', data: { message: message(assistantId, 'assistant', 'assistant') } }), + JSON.stringify({ type: 'tool/result', data: { message: message(toolId, 'tool', 'tool') } }), + JSON.stringify({ type: 'turn/start', data: { id: userId } }), + JSON.stringify({ type: 'steering/message', data: message(userId, 'user', 'obsolete') }), + JSON.stringify({ type: 'user/message', data: { ...message(userId, 'user', 'malformed'), source: null } }), + JSON.stringify({ type: 'user/message', data: message(malformedId, 'user', 'non-UUID') }), + JSON.stringify({ type: 'assistant/message', data: null }), + JSON.stringify({ type: 42, data: message(userId, 'user', 'non-string type') }), + '', + ].join('\n') + + const stable = stabilizeFixtureMessageIds( + [log(ids.freshUser, ids.freshAssistant, ids.freshTool, 'not-a-uuid')], + [log(ids.oldUser, ids.oldAssistant, ids.oldTool, ids.oldMalformed)], + )[0] as string + const records = stable.trim().split('\n').map(line => JSON.parse(line) as Record) + + const inserted = ((records[1]?.data as { inserted: Array<{ id: string }> }).inserted) + expect(inserted[0]?.id).toBe(ids.oldUser) + expect(inserted[1]?.id).toBe(ids.freshUser) + expect((records[2]?.data as { id: string }).id).toBe(ids.oldUser) + expect((records[3]?.data as { message: { id: string } }).message.id).toBe(ids.oldAssistant) + expect((records[4]?.data as { message: { id: string } }).message.id).toBe(ids.oldTool) + expect((records[5]?.data as { id: string }).id).toBe(ids.freshUser) + expect((records[6]?.data as { id: string }).id).toBe(ids.freshUser) + expect((records[7]?.data as { id: string }).id).toBe(ids.freshUser) + expect((records[8]?.data as { id: string }).id).toBe('not-a-uuid') + }) + + it('matches cwd-bearing messages only after the fresh log reaches fixture-ready form', () => { + const freshId = '11111111-1111-4111-8111-111111111111' + const existingId = '22222222-2222-4222-8222-222222222222' + const freshCwd = '/tmp/acp-snapshot-fresh-cwd' + const message = (id: string, path: string): Record => ({ + type: 'user/message', + data: { + id, + role: 'user', + content: [{ type: 'text', text: `read ${path}/input.txt` }], + source: { kind: 'user' }, + }, + }) + const fresh = tokenizeSessionFixtureCwd([ + JSON.stringify({ type: 'session', id: 'fresh', cwd: freshCwd }), + JSON.stringify(message(freshId, freshCwd)), + '', + ].join('\n')) + const existing = [ + JSON.stringify({ type: 'session', id: 'old', cwd: '{{cwd}}' }), + JSON.stringify(message(existingId, '{{cwd}}')), + '', + ].join('\n') + + expect(stabilizeFixtureMessageIds([fresh], [existing])[0]).toContain(`"id":"${existingId}"`) + }) + + it('rejects a fingerprint connected to an id that also identifies different content', () => { + const freshId = '11111111-1111-4111-8111-111111111111' + const conflictingId = '22222222-2222-4222-8222-222222222222' + const competingId = '33333333-3333-4333-8333-333333333333' + const message = (id: string, text: string): string => JSON.stringify({ + type: 'user/message', + data: { id, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }, + }) + const fresh = `${message(freshId, 'shared')}\n` + const existing = [ + message(conflictingId, 'shared'), + message(conflictingId, 'different'), + message(competingId, 'shared'), + '', + ].join('\n') + + expect(stabilizeFixtureMessageIds([fresh], [existing])).toEqual([fresh]) + }) + it('leaves fresh fixtures unchanged when no committed counterpart exists', () => { const fresh = '{"type":"session","id":"new"}\n' expect(stabilizeFixtureMessageIds([fresh], [''])).toEqual([fresh]) @@ -726,76 +830,28 @@ describe('refreshFixtureReplacements', () => { ]) }) - it('maps one inherited message id across parent and child logs', () => { + it('leaves complete message ids out of the literal refresh replacement list', () => { const freshMessageId = '11111111-1111-4111-8111-111111111111' const existingMessageId = '22222222-2222-4222-8222-222222222222' - const content = [{ type: 'text', text: 'inherited' }] const log = (sessionId: string, messageId: string): string => [ JSON.stringify({ type: 'session', id: sessionId, cwd: '/same' }), JSON.stringify({ type: 'user/message', - data: { role: 'user', content, source: { kind: 'user' }, id: messageId }, + data: { + id: messageId, + role: 'user', + content: [{ type: 'text', text: 'same' }], + source: { kind: 'user' }, + }, }), '', ].join('\n') - const harvested = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content }) - const replacements = refreshFixtureReplacements( - [harvested(log('fresh-parent', freshMessageId)), harvested(log('fresh-child', freshMessageId))], - [log('old-parent', existingMessageId), log('old-child', existingMessageId)], + [{ id: 'diagnostic', createdAt: 1, content: log('fresh', freshMessageId) }], + [log('old', existingMessageId)], ) - expect(replacements.filter(replacement => replacement.from === freshMessageId)).toEqual([ - { from: freshMessageId, to: existingMessageId }, - ]) - }) - - it('keeps fresh ids for new, changed, and ambiguous messages', () => { - const ids = { - new: '11111111-1111-4111-8111-111111111111', - changed: '22222222-2222-4222-8222-222222222222', - ambiguousA: '33333333-3333-4333-8333-333333333333', - ambiguousB: '44444444-4444-4444-8444-444444444444', - oldChanged: '55555555-5555-4555-8555-555555555555', - oldAmbiguous: '66666666-6666-4666-8666-666666666666', - stable: '77777777-7777-4777-8777-777777777777', - } as const - const message = (id: string, text: string): Record => ({ - type: 'user/message', - data: { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' }, id }, - }) - const log = (messages: Record[]): string => [ - JSON.stringify({ type: 'session', id: 'same', cwd: '/same' }), - ...messages.map(record => JSON.stringify(record)), - '', - ].join('\n') - const fresh = log([ - message(ids.new, 'new'), - message(ids.changed, 'changed'), - message(ids.changed, 'changed again'), - message(ids.ambiguousA, 'duplicate'), - message(ids.ambiguousB, 'duplicate'), - message(ids.stable, 'stable'), - ]) - const existing = log([ - message(ids.oldChanged, 'before'), - message(ids.oldAmbiguous, 'duplicate'), - message(ids.stable, 'stable'), - ]) - - const replacements = refreshFixtureReplacements( - [{ id: 'diagnostic', createdAt: 1, content: fresh }], - [existing], - ) - - const replacedIds = replacements.map(replacement => replacement.from) - for (const id of [ - ids.new, - ids.changed, - ids.ambiguousA, - ids.ambiguousB, - ids.stable, - ]) expect(replacedIds).not.toContain(id) + expect(replacements).toEqual([{ from: 'fresh', to: 'old' }]) }) }) @@ -936,13 +992,38 @@ describe('stabilizeRefreshLog', () => { [{ id: 'diagnostic', createdAt: 1, content: fresh }], [existing], ) - const output = stabilize(fresh, existing, replacements).trim().split('\n') + const refreshed = stabilize(fresh, existing, replacements) + const intermediate = refreshed.trim().split('\n') + .map(line => JSON.parse(line) as Record) + expect((intermediate[1]?.data as { id: string }).id).toBe(freshUserId) + expect(((intermediate[3]?.data as { message: { id: string } }).message).id).toBe(freshAssistantId) + + const output = (stabilizeFixtureMessageIds([refreshed], [existing])[0] as string).trim().split('\n') .map(line => JSON.parse(line) as Record) expect((output[1]?.data as { id: string }).id).toBe(existingUserId) expect(((output[3]?.data as { message: { id: string } }).message).id).toBe(existingAssistantId) }) + it('leaves an aligned complete message id to the fixture-ready structural pass', () => { + const freshId = '11111111-1111-4111-8111-111111111111' + const existingId = '22222222-2222-4222-8222-222222222222' + const log = (id: string): string => [ + JSON.stringify({ type: 'session', id: 'same', createdAt: 1, cwd: '/same' }), + JSON.stringify({ + type: 'user/message', + data: { id, role: 'user', content: [{ type: 'text', text: 'same' }], source: { kind: 'user' } }, + }), + '', + ].join('\n') + const fresh = log(freshId) + const existing = log(existingId) + const refreshed = stabilize(fresh, existing) + + expect(refreshed).toContain(`"id":"${freshId}"`) + expect(stabilizeFixtureMessageIds([refreshed], [existing])[0]).toContain(`"id":"${existingId}"`) + }) + it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => { const fresh = [ '{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}', diff --git a/packages/support/acp-snapshot/tsconfig.json b/packages/support/acp-snapshot/tsconfig.json index 893282ce51..9d85ccaf1f 100644 --- a/packages/support/acp-snapshot/tsconfig.json +++ b/packages/support/acp-snapshot/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../core/session" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 078f775ecf..757da9b662 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5907,6 +5907,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From de93253b148c737355f0d700843211b5916ec3fd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:18:40 +0800 Subject: [PATCH 030/100] docs: refresh module graph --- docs/module-graph.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index d963273363..bd9bcbf070 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -300,7 +300,6 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_skill --> pkg_invariants - pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants pkg_base --> pkg_invariants @@ -422,6 +421,8 @@ flowchart TD pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session + pkg_acp_snapshot --> pkg_invariants + pkg_acp_snapshot --> pkg_session pkg_app_boot --> pkg_environment pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths @@ -1168,7 +1169,6 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | -| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | @@ -1220,6 +1220,7 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | From 9c3d5725a5735f72d1e6dbbb3d22c7de5e17d758 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:29:12 +0800 Subject: [PATCH 031/100] build: order Host and Client compilation faces --- package.json | 7 +- packages/api/remotes/tsconfig.client.json | 22 ++++ packages/api/remotes/tsconfig.host.json | 36 ++++++ packages/api/remotes/tsconfig.json | 37 +----- packages/api/remotes/tsdown.config.ts | 6 +- packages/client/runtime/package.json | 3 - packages/client/runtime/src/client/index.ts | 5 +- packages/client/runtime/tsconfig.json | 3 - packages/client/schema-form/tsdown.config.ts | 6 + packages/client/test-runtime/tsdown.config.ts | 6 + packages/client/tsdown.client.ts | 109 ++++++++++++++++-- packages/client/ui-goal/tsconfig.json | 2 +- .../client/ui-primitives/tsdown.config.ts | 6 +- packages/client/ui-slots/tsdown.config.ts | 6 + packages/client/ui-theme/tsdown.config.ts | 12 +- packages/client/web-react/tsdown.config.ts | 4 +- packages/client/web/tsdown.config.ts | 6 +- packages/host/apiproxy/tsconfig.json | 2 +- .../directory-picker-native/tsdown.config.ts | 29 ++--- packages/typert/generator/src/analyzer.ts | 17 ++- .../typert/generator/tests/type-model.spec.ts | 82 +++++++++++++ pnpm-lock.yaml | 3 - scripts/client-bundle-css.spec.ts | 9 +- scripts/client-bundle-purity.spec.ts | 42 +++++-- scripts/doc-typecheck.ts | 29 ++--- scripts/package-invariants.spec.ts | 14 +++ scripts/package-invariants.ts | 30 ++++- scripts/wine-windows-gates.sh | 16 ++- tsconfig.client.json | 2 +- tsconfig.host.json | 1 + tsdown.config.ts | 52 ++++----- tsdown.typert-host.config.ts | 20 ---- 32 files changed, 440 insertions(+), 184 deletions(-) create mode 100644 packages/api/remotes/tsconfig.client.json create mode 100644 packages/api/remotes/tsconfig.host.json create mode 100644 packages/client/schema-form/tsdown.config.ts create mode 100644 packages/client/test-runtime/tsdown.config.ts create mode 100644 packages/client/ui-slots/tsdown.config.ts delete mode 100644 tsdown.typert-host.config.ts diff --git a/package.json b/package.json index b1ad6853fc..def6e5bdcb 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,12 @@ "scripts": { "build": "npm run build:lib && npm run build:web", "build:lib": "npm run build:lib:host && npm run build:lib:client", - "build:lib:host": "npm run build:lib:contracts && tsc -b tsconfig.host.json", - "build:lib:contracts": "tsc -b packages/typert/generator && tsdown --config tsdown.typert-host.config.ts", - "build:lib:client": "tsc -b tsconfig.client.json && tsdown", + "build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "npm run build:lib:contracts && tsc -b", + "typecheck": "npm run build:lib:host && tsc -b tsconfig.client.json", "lint": "tsx scripts/run-oxlint.ts .", "lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json new file mode 100644 index 0000000000..bc26c0b13f --- /dev/null +++ b/packages/api/remotes/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../goal/goal" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json new file mode 100644 index 0000000000..1d4c35a9e2 --- /dev/null +++ b/packages/api/remotes/tsconfig.host.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/agent-lookup.ts", + "src/index.ts", + "src/invariant.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../typert/registry" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/api/remotes/tsconfig.json b/packages/api/remotes/tsconfig.json index 148804dc0f..2eca820546 100644 --- a/packages/api/remotes/tsconfig.json +++ b/packages/api/remotes/tsconfig.json @@ -1,42 +1,11 @@ { - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ { - "path": "../../../vendor/cordis" + "path": "./tsconfig.host.json" }, { - "path": "../../core/agent" - }, - { - "path": "../../core/session" - }, - { - "path": "../../session-persistence/session-persistence" - }, - { - "path": "../../typert/type-meta" - }, - { - "path": "../../typert/registry" - }, - { - "path": "../../ui/commands" - }, - { - "path": "../../goal/goal" - }, - { - "path": "../../session-title/session-title" - }, - { - "path": "../../support/invariants" + "path": "./tsconfig.client.json" } ] } diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts index 287b2c7975..3c72df8718 100644 --- a/packages/api/remotes/tsdown.config.ts +++ b/packages/api/remotes/tsdown.config.ts @@ -1,3 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-api-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle( + '@deepseek-ai/dsh-api-remotes', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 711510b705..3d97e70de5 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -25,7 +25,6 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-typert-registry" ], "platform": "web", @@ -49,14 +48,12 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-api-remotes": "^0.0.1", "@deepseek-ai/dsh-type-meta": "^0.0.1", "@deepseek-ai/dsh-typert-registry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 5a1677df96..e4f8e57b04 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,7 +1,6 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' @@ -179,8 +178,8 @@ declare module 'cordis' { } } -/** Required services: the Remote root, wire handle, and Client TypeRT registry. */ -export const inject = ['remote', 'connection', 'typert'] +/** Required services: the wire handle and Client TypeRT registry. */ +export const inject = ['connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index efbf7c26d7..f93546e855 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../connection" }, - { - "path": "../../api/remotes" - }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/schema-form/tsdown.config.ts b/packages/client/schema-form/tsdown.config.ts new file mode 100644 index 0000000000..b03542c74e --- /dev/null +++ b/packages/client/schema-form/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-client-schema-form', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/test-runtime/tsdown.config.ts b/packages/client/test-runtime/tsdown.config.ts new file mode 100644 index 0000000000..e2cb484ffb --- /dev/null +++ b/packages/client/test-runtime/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-client-test-runtime', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 74facbd69b..f2b7b7a3e6 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -9,6 +9,7 @@ * The virtual loader registers each real stylesheet as a watch dependency. */ import { readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path' import { fileURLToPath } from 'node:url' import type { UserConfig } from 'tsdown' @@ -34,6 +35,12 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools| /** Generated descriptor/codec contribution with no shared runtime identity. */ const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/ +/** + * Workspace mode replaces an empty config array with the root defaults. A + * falsey entry instead removes this package before entry resolution. + */ +const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' } + /** * Documented TEMPORARY exemption, not a platform module (hence not in * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ @@ -61,19 +68,83 @@ function browserSourcePath(source: string, sourcemapPath: string): string { /** * Build the tsdown config for one UI plugin package: the node-half lib build - * plus the browser client bundle. A package-level tsdown.config.ts REPLACES - * the root workspace shape, so the lib half must be restated here — dropping - * it leaves the package without lib/index.js and the host Loader cannot - * import its node half. + * plus the browser client bundle. Client packages emit both halves during the + * Client pass by default; packages needed for Host reflection may opt into the + * earlier Host pass. A package-level tsdown.config.ts REPLACES the root + * workspace shape, so the lib half must be restated here — dropping it leaves + * the package without lib/index.js and the host Loader cannot import its node + * half. * @param id - plugin id (package name), stamped into the __ModuleLoader__.load * handoff and onto the injected style tags. * @param libEntry - node-half entries, spelled at the call site so the * package-invariants gate can see `lib/types/invariant.js` in each package's * own tsdown.config.ts (a preset-side glob hides it from the mechanical check). - * @returns tsdown user configs emitting lib/*.js and lib/client.js. + * @param options - phase placement, lib overrides, and companion Node configs. + * @returns ENV-selected tsdown config for the current build face. */ -export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] { - return [{ +export function clientBundle( + id: string, + libEntry: readonly string[], + options: ClientBundleOptions = {}, +): BuildFaceConfig { + const lib = clientLibraryConfig(id, libEntry, options.lib) + return ({ env }) => { + const face = buildFace(env?.DSH_BUILD_FACE) + const client = clientConfig(id, face === undefined + ? 'src/client/index.ts' + : 'lib/types/client/index.js') + const host = [lib, ...(options.host ?? [])] + if (face === 'host') return options.hostPhase === true ? host : [SKIP_WORKSPACE_BUILD] + if (face === 'client') return options.hostPhase === true ? [client] : [...host, client] + return [...host, client] + } +} + +/** + * Build a Client-only Node library during the Client pass. + * @param id - Package name used in tsdown diagnostics. + * @param libEntry - Emitted JavaScript entries consumed from `lib/types`. + * @returns ENV-selected tsdown config for the Client build face. + */ +export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig { + const lib = clientLibraryConfig(id, libEntry) + return clientOnly([lib]) +} + +/** + * Select arbitrary package-local configs only during the Client pass. + * @param configs - Node-side configs emitted after Client tsc. + * @returns ENV-selected tsdown config for the Client build face. + */ +export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig { + return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host' + ? [SKIP_WORKSPACE_BUILD] + : [...configs] +} + +interface ClientBundleOptions { + /** Emit the Node-side artifacts during the Host pass instead of the Client pass. */ + readonly hostPhase?: boolean + readonly host?: readonly UserConfig[] + readonly lib?: UserConfig +} + +type BuildFace = 'host' | 'client' | undefined + +type BuildFaceConfig = (inlineConfig: Pick) => UserConfig[] + +function buildFace(value: unknown): BuildFace { + if (value === undefined || value === 'host' || value === 'client') return value + throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`) +} + +function clientLibraryConfig( + id: string, + libEntry: readonly string[], + overrides: UserConfig = {}, +): UserConfig { + return { + name: id, entry: [...libEntry], outDir: 'lib', format: ['esm'], @@ -82,8 +153,14 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf fixedExtension: false, dts: false, clean: false, - }, { - entry: { client: 'src/client/index.ts' }, + ...overrides, + } +} + +function clientConfig(id: string, entry: string): UserConfig { + return { + name: `${id}/client`, + entry: { client: entry }, // Browser bundle lands next to the node half (single lib/ artifact dir; // the entryFileNames pin keeps it exactly lib/client.js). clean must stay // off — a default clean would wipe the node-half output emitted above. @@ -139,7 +216,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf name: 'dsh-css-modules-inline', resolveId(source: string, importer: string | undefined) { if (!source.endsWith('.module.css')) return null - const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source + const abs = importer !== undefined ? sourceAssetPath(source, importer) : source return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX }, async load(virtualId: string) { @@ -182,5 +259,15 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf footer: `return module.exports; } });`, intro: 'var module = { exports: {} }; var exports = module.exports;', }, - }] + } +} + +/** Resolve an emitted JS asset import against its source-tree counterpart. */ +function sourceAssetPath(source: string, importer: string): string { + const emitted = resolvePath(dirname(importer), source) + if (existsSync(emitted)) return emitted + const marker = `${sep}lib${sep}types${sep}` + const boundary = emitted.indexOf(marker) + if (boundary < 0) return emitted + return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length)) } diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 263dfceb26..1c89771abf 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -15,7 +15,7 @@ "path": "../locale" }, { - "path": "../../api/remotes" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../runtime" diff --git a/packages/client/ui-primitives/tsdown.config.ts b/packages/client/ui-primitives/tsdown.config.ts index 1532f5e5f6..cbabf4f2a1 100644 --- a/packages/client/ui-primitives/tsdown.config.ts +++ b/packages/client/ui-primitives/tsdown.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'tsdown' +import { clientOnly } from '../tsdown.client.ts' /** * ui-primitives is browser-only, but its lib bundle IS imported under plain @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * (loader module table / vite source paths), which compile src directly and * never read lib. */ -export default defineConfig({ +export default clientOnly([{ entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], @@ -28,4 +28,4 @@ export default defineConfig({ return 'export default {};' }, }], -}) +}]) diff --git a/packages/client/ui-slots/tsdown.config.ts b/packages/client/ui-slots/tsdown.config.ts new file mode 100644 index 0000000000..b199e31976 --- /dev/null +++ b/packages/client/ui-slots/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-client-ui-slots', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/ui-theme/tsdown.config.ts b/packages/client/ui-theme/tsdown.config.ts index 08616753ce..25b80eef68 100644 --- a/packages/client/ui-theme/tsdown.config.ts +++ b/packages/client/ui-theme/tsdown.config.ts @@ -1,11 +1,11 @@ import { clientBundle } from '../tsdown.client.ts' -const [lib, client] = clientBundle( +export default clientBundle( '@deepseek-ai/dsh-client-ui-theme', ['lib/types/index.js', 'lib/types/invariant.js'], + { + lib: { + copy: [{ from: 'src/styles/*', to: 'lib/styles' }], + }, + }, ) - -export default [{ - ...lib, - copy: [{ from: 'src/styles/*', to: 'lib/styles' }], -}, client] diff --git a/packages/client/web-react/tsdown.config.ts b/packages/client/web-react/tsdown.config.ts index 65378be678..676d6ce415 100644 --- a/packages/client/web-react/tsdown.config.ts +++ b/packages/client/web-react/tsdown.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'tsdown' +import { clientOnly } from '../tsdown.client.ts' /** * Root and invariant shapes as SEPARATE single-entry bundles: a multi-entry @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * runtime — browser consumers resolve this package through the loader module * table. */ -export default defineConfig([ +export default clientOnly([ { entry: { index: 'lib/types/index.js' }, outDir: 'lib', diff --git a/packages/client/web/tsdown.config.ts b/packages/client/web/tsdown.config.ts index 8040221e14..78527fba80 100644 --- a/packages/client/web/tsdown.config.ts +++ b/packages/client/web/tsdown.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'tsdown' +import { clientOnly } from '../tsdown.client.ts' /** * Root-shape lib build plus a css stub: the shell's components import @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * this node lib build stubs every css import to an empty module — importing * the lib under plain node must not crash on an asset specifier. */ -export default defineConfig({ +export default clientOnly([{ entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], @@ -28,4 +28,4 @@ export default defineConfig({ return 'export default {};' }, }], -}) +}]) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 912f2cd794..8686a9e468 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -24,7 +24,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../../api/remotes" + "path": "../../api/remotes/tsconfig.host.json" }, { "path": "../../util/brand" diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 529fb7b2ac..4a4727a5aa 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -3,18 +3,21 @@ import { clientBundle } from '../../client/tsdown.client.ts' // The Win32 dialog worker builds as its own CJS entry (mirroring // dsh-workflow-workerthread's worker): path-loaded by the driver, inlining // the dialog logic while koffi stays an external native require. -export default [ - ...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']), +export default clientBundle( + '@deepseek-ai/dsh-host-directory-picker-native', + ['lib/types/index.js', 'lib/types/invariant.js'], { - // The artifact is lib/worker.cjs (the ./worker export the workspace - // constraint keys on), bundled from the descriptive source entry. - entry: { worker: 'lib/types/win32-dialog-worker.js' }, - outDir: 'lib', - format: ['cjs'] as ['cjs'], - platform: 'node' as const, - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, + host: [{ + // The artifact is lib/worker.cjs (the ./worker export the workspace + // constraint keys on), bundled from the descriptive source entry. + entry: { worker: 'lib/types/win32-dialog-worker.js' }, + outDir: 'lib', + format: ['cjs'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }], }, -] +) diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index c5d89b3726..6f495aa318 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -476,11 +476,20 @@ export class WorkspaceAnalyzer { config: this.caches.config(configPath), manifest, } - if (isDualFacePackage(manifest)) { - registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) }) - registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) }) - } else { + if (!isDualFacePackage(manifest)) { registrations.push(registration) + } else if (configPath === join(packageRoot, 'tsconfig.json')) { + registrations.push( + { ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) }, + { ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) }, + ) + } else { + registrations.push({ + ...registration, + exportSubpaths: face === 'host' + ? hostExportSubpaths(manifest) + : clientExportSubpaths(manifest), + }) } } } diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index ca37cd1bbe..40a91e3ef5 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -864,6 +864,31 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { .toEqual(['@fixture/host']) }) + it('keeps both runtime faces for an ordinary dshClient project', () => { + const root = copyFixture('typert-dual-runtime-') + configureDualRuntimeClient(root, false) + + expect(new WorkspaceAnalyzer({ root }).discoverPackages()).toContainEqual({ + package: '@fixture/client', + root: 'packages/client', + faces: ['client', 'host'], + }) + }) + + it('confines explicit face projects to their selected TypeRT face', () => { + const root = copyFixture('typert-split-project-') + configureDualRuntimeClient(root, true) + + const markers = new WorkspaceAnalyzer({ root }).indexSourceDeclarations() + .filter(declaration => declaration.package === '@fixture/client' + && declaration.name.endsWith('OnlyMarker')) + .map(declaration => ({ face: declaration.face, name: declaration.name })) + expect(markers).toEqual([ + { face: 'client', name: 'ClientOnlyMarker' }, + { face: 'host', name: 'HostOnlyMarker' }, + ]) + }) + it('accepts package export forms while skipping artifact-only rows and unexported packages', { timeout: 180_000 }, () => { const root = copyFixture('typert-export-forms-') const hostRoot = join(root, 'packages/host') @@ -1193,6 +1218,63 @@ function copyFixture(prefix: string): string { return root } +function configureDualRuntimeClient(root: string, splitProjects: boolean): void { + const packageRoot = join(root, 'packages/client') + const manifestPath = join(packageRoot, 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + dshClient?: object + exports: Record + } + manifest.dshClient = {} + manifest.exports['./client'] = { + types: './lib/types/client.d.ts', + default: './lib/client.js', + } + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync(join(packageRoot, 'src/client.ts'), [ + "import { Service } from 'cordis'", + 'export interface ClientOnlyMarker { readonly client: true }', + 'export class BrowserBridge extends Service {}', + "declare module 'cordis' { interface Context { browserBridge: BrowserBridge } }", + '', + ].join('\n')) + const indexPath = join(packageRoot, 'src/index.ts') + writeFileSync(indexPath, `${readFileSync(indexPath, 'utf8')}\nexport interface HostOnlyMarker { readonly host: true }\n`) + if (!splitProjects) return + + const project = JSON.parse(readFileSync(join(packageRoot, 'tsconfig.json'), 'utf8')) as Record + delete project.include + writeFileSync(join(packageRoot, 'tsconfig.host.json'), `${JSON.stringify({ + ...project, + files: ['src/index.ts'], + }, null, 2)}\n`) + writeFileSync(join(packageRoot, 'tsconfig.client.json'), `${JSON.stringify({ + ...project, + files: ['src/client.ts'], + }, null, 2)}\n`) + writeFileSync(join(packageRoot, 'tsconfig.json'), `${JSON.stringify({ + files: [], + references: [ + { path: './tsconfig.host.json' }, + { path: './tsconfig.client.json' }, + ], + }, null, 2)}\n`) + + const hostAggregatePath = join(root, 'tsconfig.host.json') + const hostAggregate = JSON.parse(readFileSync(hostAggregatePath, 'utf8')) as { + references: { path: string }[] + } + hostAggregate.references.push({ path: './packages/client/tsconfig.host.json' }) + writeFileSync(hostAggregatePath, `${JSON.stringify(hostAggregate, null, 2)}\n`) + + const clientAggregatePath = join(root, 'tsconfig.client.json') + const clientAggregate = JSON.parse(readFileSync(clientAggregatePath, 'utf8')) as { + references: { path: string }[] + } + clientAggregate.references = [{ path: './packages/client/tsconfig.client.json' }] + writeFileSync(clientAggregatePath, `${JSON.stringify(clientAggregate, null, 2)}\n`) +} + function addSameFacePackage(root: string, specifier: string, importedName: string): void { const packageRoot = join(root, 'packages/consumer') mkdirSync(join(packageRoot, 'src'), { recursive: true }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 078f775ecf..4e50be7e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1446,9 +1446,6 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/client-bundle-css.spec.ts b/scripts/client-bundle-css.spec.ts index 30350241fc..41b3eb3679 100644 --- a/scripts/client-bundle-css.spec.ts +++ b/scripts/client-bundle-css.spec.ts @@ -15,8 +15,13 @@ interface CssPlugin { } function cssPlugin(): CssPlugin { - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - const plugins = (configs[1] as { plugins: CssPlugin[] }).plugins + const configs = clientBundle( + '@deepseek-ai/dsh-client-test', + ['lib/types/index.js', 'lib/types/invariant.js'], + )({ env: { DSH_BUILD_FACE: 'client' } }) + const client = configs.find(config => config.platform === 'browser') + if (client === undefined) throw new Error('client config missing') + const plugins = (client as { plugins: CssPlugin[] }).plugins const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline') if (plugin === undefined) throw new Error('CSS Modules plugin missing from client config') return plugin diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index fb47f8a9c8..aa3bae0b6d 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -14,6 +14,24 @@ interface CssModulePlugin { load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise } +function clientConfigs(id = '@deepseek-ai/dsh-client-test') { + return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])( + { env: { DSH_BUILD_FACE: 'client' } }, + ).filter(config => config.platform === 'browser') +} + +describe('client bundle build faces', () => { + it('watches source in development and consumes emitted JavaScript in the Client build', () => { + const bundle = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js']) + const development = bundle({ env: {} }).find(config => config.platform === 'browser') + const artifact = bundle({ env: { DSH_BUILD_FACE: 'client' } }) + .find(config => config.platform === 'browser') + + expect(development?.entry).toEqual({ client: 'src/client/index.ts' }) + expect(artifact?.entry).toEqual({ client: 'lib/types/client/index.js' }) + }) +}) + function clientSourceMapPath(packagePath: string): string { return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url)) } @@ -21,16 +39,16 @@ function clientSourceMapPath(packagePath: string): string { function purityResolveId(): ResolveId { // libEntry is spelled at every call site (no default) so the // package-invariants text check can see the invariant entry per package. - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - const plugins = (configs[1] as { plugins: { name: string; resolveId?: unknown }[] }).plugins + const configs = clientConfigs() + const plugins = (configs[0] as { plugins: { name: string; resolveId?: unknown }[] }).plugins const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity') if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config') return gate.resolveId as ResolveId } function cssModulePlugin(): CssModulePlugin { - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - const plugins = (configs[1] as { plugins: CssModulePlugin[] }).plugins + const configs = clientConfigs() + const plugins = (configs[0] as { plugins: CssModulePlugin[] }).plugins const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline') if (plugin?.resolveId === undefined || plugin.load === undefined) { throw new Error('CSS Modules plugin missing from client config') @@ -87,13 +105,13 @@ describe('client bundle purity gate', () => { describe('client bundle debug artifacts', () => { it('emits source maps for plugin TS and TSX outside the Vite module graph', () => { - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - expect(configs[1]?.sourcemap).toBe(true) + const configs = clientConfigs() + expect(configs[0]?.sourcemap).toBe(true) }) it('maps first-party sources to their repository package paths', () => { - const configs = clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js', 'lib/types/invariant.js']) - const outputOptions = configs[1]?.outputOptions + const configs = clientConfigs('@deepseek-ai/dsh-client-ui-goal') + const outputOptions = configs[0]?.outputOptions if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing') const transform = outputOptions.sourcemapPathTransform if (transform === undefined) throw new Error('client sourcemap path transform missing') @@ -105,8 +123,8 @@ describe('client bundle debug artifacts', () => { }) it('maps dual-face host sources to the host package group', () => { - const configs = clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js']) - const outputOptions = configs[1]?.outputOptions + const configs = clientConfigs('@deepseek-ai/dsh-host-directory-picker-native') + const outputOptions = configs[0]?.outputOptions if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing') const transform = outputOptions.sourcemapPathTransform if (transform === undefined) throw new Error('client sourcemap path transform missing') @@ -116,8 +134,8 @@ describe('client bundle debug artifacts', () => { }) it('maps inlined workspace sources to packages and leaves dependencies outside it unchanged', () => { - const configs = clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js']) - const outputOptions = configs[1]?.outputOptions + const configs = clientConfigs('@deepseek-ai/dsh-client-connection') + const outputOptions = configs[0]?.outputOptions if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing') const transform = outputOptions.sourcemapPathTransform if (transform === undefined) throw new Error('client sourcemap path transform missing') diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 6ae08150fb..456dedecfe 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -136,22 +136,25 @@ function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[ } /** - * Reuse the host-aggregate references from a temp project one directory below - * root. Doc fragments speak the host vocabulary, so the standalone project - * seeds tsconfig.host.json (never the root solution: flattening host+client - * into one program collides the cordis Context merges). + * Reuse both aggregate reference sets from a temp project one directory below + * root. Each referenced package remains its own program, while documentation + * examples can import either the Host or Client API. */ function workspaceReferences(): { path: string }[] { - const file = join(root, 'tsconfig.host.json') - // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path - // candidate in the workspace wildcard. - const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) - if (result.error) { - throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) + const paths = new Set() + for (const aggregate of ['tsconfig.host.json', 'tsconfig.client.json']) { + const file = join(root, aggregate) + // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path + // candidate in the workspace wildcard. + const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) + if (result.error) { + throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) + } + // `config` is typed `any` by the TS API; narrow it to the one field read here. + const { references } = result.config as { references: { path: string }[] } + for (const { path } of references) paths.add(path) } - // `config` is typed `any` by the TS API; narrow it to the one field read here. - const { references } = result.config as { references: { path: string }[] } - return references.map(({ path }) => ({ + return [...paths].map(path => ({ path: path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`, })) } diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 32705c4c87..59e56976e6 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -72,6 +72,20 @@ describe('package invariant gate', () => { expect(collectPackageInvariantViolations(fixture())).toEqual([]) }) + it('accepts an invariant reference owned by a package-local leaf project', () => { + const root = fixture({ invariantReference: false }) + const dir = join(root, 'packages/core/probe') + writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ + files: [], + references: [{ path: './tsconfig.host.json' }], + }, null, 2)}\n`) + writeFileSync(join(dir, 'tsconfig.host.json'), `${JSON.stringify({ + references: [{ path: '../../support/invariants' }], + }, null, 2)}\n`) + + expect(collectPackageInvariantViolations(root)).toEqual([]) + }) + it('rejects missing publication metadata and build output', () => { const violations = collectPackageInvariantViolations(fixture({ invariantExport: false, diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 21bc5931ec..54318ca94f 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -118,11 +118,8 @@ function checkBuild( violations: PackageInvariantViolation[], ): void { const tsconfigPath = `${owner.dir}/tsconfig.json` - const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as { - references?: Array<{ path?: string }> - } if (owner.packageName !== '@deepseek-ai/dsh-invariants' - && !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) { + && !projectReferencesInvariants(root, owner.dir, tsconfigPath)) { addViolation( violations, tsconfigPath, @@ -138,6 +135,31 @@ function checkBuild( } } +function projectReferencesInvariants(root: string, ownerDir: string, entryPath: string): boolean { + const ownerRoot = resolve(root, ownerDir) + const target = resolve(root, 'packages/support/invariants') + const pending = [resolve(root, entryPath)] + const visited = new Set() + while (pending.length > 0) { + const configPath = pending.pop() + if (configPath === undefined) break + if (visited.has(configPath)) continue + visited.add(configPath) + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + references?: Array<{ path?: string }> + } + for (const reference of config.references ?? []) { + if (reference.path === undefined) continue + const referenced = resolve(dirname(configPath), reference.path) + if (referenced === target) return true + if (!referenced.startsWith(`${ownerRoot}${sep}`)) continue + const childConfig = referenced.endsWith('.json') ? referenced : resolve(referenced, 'tsconfig.json') + if (existsSync(childConfig)) pending.push(childConfig) + } + } + return false +} + function checkSource( owner: PackageInvariantOwner, root: string, diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 1f5de1dcbd..b6d8ffffb0 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -204,15 +204,14 @@ cat "$scratch/logs/smoke.log" grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; } # ---- the two blocking surfaces, concurrently ------------------------------ -# The build preserves the face order from package.json: generate Host contracts -# before either aggregate typecheck, then bundle the completed workspace. +# The build preserves the face order from package.json: compile and bundle the +# Host face before compiling and bundling the Client face. # Both statuses are captured so one failure cannot hide the other's result. build_gate() { - wine_node "$scratch/logs/contracts-tsc.log" "$tsc_js" -b packages/typert/generator --pretty false || return $? - wine_node "$scratch/logs/contracts-tsdown.log" "$tsdown_js" --config tsdown.typert-host.config.ts || return $? wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? - wine_node "$scratch/logs/tsdown.log" "$tsdown_js" + wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client } site_gate() { cd website @@ -238,12 +237,11 @@ report() { for log in "$@"; do tail -n 200 "$log" >&2 || true; done fi } -report 'build (contract prepass, tsc, tsdown)' "$build_status" \ - "$scratch/logs/contracts-tsc.log" \ - "$scratch/logs/contracts-tsdown.log" \ +report 'build (Host tsc/tsdown, Client tsc/tsdown)' "$build_status" \ "$scratch/logs/host-tsc.log" \ + "$scratch/logs/host-tsdown.log" \ "$scratch/logs/client-tsc.log" \ - "$scratch/logs/tsdown.log" + "$scratch/logs/client-tsdown.log" report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log" if (( build_status != 0 )); then exit "$build_status"; fi exit "$site_status" diff --git a/tsconfig.client.json b/tsconfig.client.json index 9821c0e41b..2a2b16e2e7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -53,7 +53,7 @@ { "path": "./packages/client/connection" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway" }, - { "path": "./packages/api/remotes" }, + { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 61e42e2fc6..7e7cd982fc 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -104,6 +104,7 @@ { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway" }, + { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-checkpoint-policy" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 0d503c62d3..2042e81db3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,34 +1,30 @@ import { defineConfig } from 'tsdown' import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' +function isBuildFaceClient(value: unknown): boolean { + if (value === undefined || value === 'host') return false + if (value === 'client') return true + throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`) +} + /** - * JS bundling for vendored Cordis and Harness TypeScript packages. - * TypeScript source is compiled first by `tsc -b` (the root solution); tsdown - * reads only the emitted JS under lib/types and writes the package root and - * invariant companion runtime bundles. Declarations are NOT produced here, - * hence `dts: false`. - * - * Per-package shape overrides live in `/tsdown.config.ts` - * (schemastery: dual ESM+CJS; logger-console: extra browser entry). + * The ordinary workspace build consumes JavaScript emitted by the Host + * TypeScript project and runs TypeRT. The Client pass selects packages that + * declare a browser bundle and lets their package-local configs emit both + * their Node loader entry and browser artifact. */ -export default defineConfig({ - // Explicit globs keep bundling to vendored Cordis, the TypeScript package tree, and - // the Node CLI assembly. `apps/web` is a Vite application with no lib/types entry; - // `workspace: true` or `apps/*` would incorrectly treat it as a package bundle. - workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], - // The brace glob admits the package companion when present while retaining the - // index-only build for vendored Cordis packages outside the Harness package tree. - entry: ['lib/types/{index,invariant}.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - // All packages set "type": "module"; fixedExtension false keeps ESM output - // at .js (not .mjs), matching the package.json main/exports fields. - fixedExtension: false, - dts: false, - clean: false, - // The final pass sees both independent TypeScript faces. Workspace mode - // writes only packages that explicitly publish a Typert/Remote subpath. - plugins: [typertPlugin({ mode: 'workspace' })], +export default defineConfig(({ env }) => { + const client = isBuildFaceClient(env?.DSH_BUILD_FACE) + return { + workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], + entry: client ? '' : ['lib/types/{index,invariant}.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + plugins: client ? [] : [typertPlugin({ mode: 'workspace', faces: ['host'] })], + } }) diff --git a/tsdown.typert-host.config.ts b/tsdown.typert-host.config.ts deleted file mode 100644 index 8c8ae11dd1..0000000000 --- a/tsdown.typert-host.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig } from 'tsdown' -import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' - -/** - * Host-only TypeRT contract prepass. The generator and its project references - * are compiled first; the plugin then analyzes Host source and emits local and - * Host-for-Client artifacts before either aggregate consumes Remote subpaths. - */ -export default defineConfig({ - workspace: ['packages/typert/generator'], - entry: ['lib/types/{index,invariant}.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - plugins: [typertPlugin({ mode: 'workspace', faces: ['host'] })], -}) From 8865548ee2397927866e0ca6ddc6d777069f6af4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:41:35 +0800 Subject: [PATCH 032/100] docs: explain generated Remote build order --- .../2026-06-17-ts-build-config.i18n.yaml | 4 +- .../process/2026-06-17-ts-build-config.md | 29 +- .../process/2026-06-17-ts-build-config.zh.md | 29 +- ...fig-solution-root-two-aggregates.i18n.yaml | 6 +- ...2-tsconfig-solution-root-two-aggregates.md | 6 +- ...sconfig-solution-root-two-aggregates.zh.md | 6 +- ...remotes-generated-contract-build.i18n.yaml | 6 + ...08-api-remotes-generated-contract-build.md | 80 ++++ ...api-remotes-generated-contract-build.zh.md | 80 ++++ AGENTS.md | 2 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 12 +- docs/api-gateway.zh.md | 12 +- docs/cookbook/adding-a-package.i18n.yaml | 4 +- docs/cookbook/adding-a-package.md | 2 +- docs/cookbook/adding-a-package.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 35 +- docs/development.zh.md | 35 +- docs/module-graph.md | 343 +++++++++--------- packages/AGENTS.md | 2 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 8 + packages/api/remotes/README.zh.md | 8 + packages/typert/generator/README.i18n.yaml | 4 +- packages/typert/generator/README.md | 4 +- packages/typert/generator/README.zh.md | 4 +- .../request-response.expected.json | 4 +- 28 files changed, 480 insertions(+), 259 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md create mode 100644 .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index fe42a92f75..a126f8c0d0 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-17-ts-build-config.md -2026-06-17-ts-build-config.md: ec4c6a4aeb1074a69d45b2cf4b4c733b410ccceb -2026-06-17-ts-build-config.zh.md: 8115bb2557eea967d57eb6693e2bb288188792a8 +2026-06-17-ts-build-config.md: f25731921aaa6da7a4c9760244f73dc0670dc1d3 +2026-06-17-ts-build-config.zh.md: 41791129647b6a5bfeb966e05ee2c5b9132c45ef diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index ec4c6a4aeb..f25731921a 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-06-17-ts-build-config.zh.md) -> Root project topology (which tsconfig owns which graph) has since moved to a solution root over two aggregate programs; see the [solution-root note](2026-07-22-tsconfig-solution-root-two-aggregates.md). The tsc-first pipeline decided here is unchanged. +> Root project topology uses a solution root over two aggregate programs; see the [solution-root note](2026-07-22-tsconfig-solution-root-two-aggregates.md). The [API Remotes build note](2026-08-08-api-remotes-generated-contract-build.md) defines the current command order in which the Host generates Remote contracts before the Client compiles. The tsc-first ownership decided here is unchanged. ## Problem @@ -30,18 +30,15 @@ Validation found several concrete technical issues and possible routes: In-package relative imports use explicit `.ts` specifiers. -`pnpm run build` is a two-stage build: +`pnpm run build` orders Host lib, Client lib, and Web; each lib phase keeps tsc emission before tsdown bundling: -- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. Publication keeps `.d.ts`; packages whose runtime exports explicitly point into the emitted tree also keep its `.js` files. `.js.map` and `.d.ts.map` remain in the local build tree. - - The graph is the project-reference graph reachable from the root solution `tsconfig.json` through the two aggregates ([topology](2026-07-22-tsconfig-solution-root-two-aggregates.md)). It validates and emits package/vendor build results. -- Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. +- Host tsc runs `tsc -b` against `tsconfig.host.json`, emitting per-module `.js`, `.d.ts`, `.js.map`, and `.d.ts.map` into `lib/types` for each package in the Host graph; Host tsdown then reads that JavaScript, produces published entries, and runs Host TypeRT. +- Client tsc runs `tsc -b` against `tsconfig.client.json` after Host TypeRT has generated the Remote Client declarations; Client tsdown then reads the JavaScript emitted by the Client graph and produces the Client packages' Node loader entries and browser bundles. +- The Web build starts only after both lib phases complete. `tsdown` is no longer the owner of TypeScript compilation or declaration output. -`pnpm run typecheck` runs the same `tsc -b` graph. -- The aggregates (`tsconfig.host.json`, `tsconfig.client.json`) typecheck examples, tests, and scripts with `noEmit`, and validate package/vendor source through references. -- Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. -- The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. +`pnpm run typecheck` first runs the Host lib phase to generate the Remote declarations required by Client typechecking, then runs `tsc -b` against `tsconfig.client.json`. The two aggregates themselves check their respective examples, tests, and scripts with `noEmit`; referenced package/vendor projects retain the same emit behavior as the build. Composite projects keep their incremental build information inside their project-local `lib/` output. `pnpm run clean` derives live output directories from the root TypeScript project-reference graph, removes legacy root build information, and removes deleted `packages/*/*` directories that contain only known generated residue. Before removing an existing target, it resolves the target's parent and refuses it if that resolved parent is outside the repository, so a symlinked project reference cannot redirect cleanup outside the checkout. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. @@ -49,14 +46,18 @@ The command orchestration shape is: ```sh pnpm run build: -tsc -b -tsdown +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web pnpm run verify-node-next-types: tsx scripts/verify-node-next-types.ts pnpm run typecheck: -tsc -b +pnpm run build:lib:host +tsc -b tsconfig.client.json pnpm run clean: tsx scripts/clean.ts @@ -75,8 +76,8 @@ The source-mode demos run through their declared TypeScript launchers and the ro Build responsibilities are clearer: -- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`. -- The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. +- Each ordinary module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`. `api/remotes` is the sole exception: generated-contract ordering requires one solution and two mutually exclusive emitting projects. +- The `build` command runs the Host and Client Project Reference graphs in order. In each phase, `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, while the bundler owns only the published runtime bundles. - `lib/types/*.d.ts` is the publish declaration output; `.d.ts.map` remains only as a local compilation artifact. - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. - `lib/types/*.js` is normally only a bundler input. It is published only when an explicit runtime export points into the emitted tree. diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 8115bb2557..4179112964 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-06-17-ts-build-config.md) | 中文 -> 根项目拓扑(即哪个 tsconfig 拥有哪张图)后来改为由一个 solution 根文件统辖两个聚合 program;见[solution 根文件 Agent Note](2026-07-22-tsconfig-solution-root-two-aggregates.md)。本文确定的 TSC 优先流水线保持不变。 +> 根项目拓扑由一个 solution 根文件统辖两个 aggregate program;见 [solution 根文件 Agent Note](2026-07-22-tsconfig-solution-root-two-aggregates.md)。Host 生成 Remote 契约后再编译 Client 的当前命令顺序见 [API Remotes 构建 Agent Note](2026-08-08-api-remotes-generated-contract-build.md)。本文确定的 tsc-first 职责保持不变。 ## 问题 @@ -30,18 +30,15 @@ Status: implemented 包内相对导入使用显式 `.ts` 说明符。 -`pnpm run build` 是两阶段构建: +`pnpm run build` 按 Host lib、Client lib 和 Web 排序;每个 lib 阶段都保持 tsc 先发射、tsdown 后打包: -- 阶段 1:在根 solution 上执行 `tsc -b`,将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各包的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts`;如果包的运行时 export 显式指向该输出树,也会保留其中的 `.js` 文件。`.js.map` 和 `.d.ts.map` 留在本地构建树中。 - - 该图是从根 solution `tsconfig.json` 经两个聚合可达的 project-reference 图([拓扑](2026-07-22-tsconfig-solution-root-two-aggregates.md)),用于校验并输出包/vendor 的构建结果。 -- 阶段 2:打包器读取 `lib/types` 下输出的 JS,将打包后的运行时入口写为 `lib/index.js` 或 `lib/index.mjs`(沿用当前行为)。此阶段仅做打包,禁止读取 TypeScript 源码或输出声明文件。 +- Host tsc 对 `tsconfig.host.json` 执行 `tsc -b`,把逐模块 `.js`、`.d.ts`、`.js.map` 与 `.d.ts.map` 输出到 Host 图各 package 的 `lib/types`;Host tsdown 随后读取这些 JS,生成发布入口并运行 Host TypeRT。 +- Client tsc 在 Host TypeRT 已生成 Remote Client 声明后对 `tsconfig.client.json` 执行 `tsc -b`;Client tsdown 再读取 Client 图发射的 JS,生成 Client package 的 Node loader 入口与 browser bundle。 +- Web build 只在两个 lib 阶段完成后启动。 `tsdown` 不再负责 TypeScript 编译或声明文件输出。 -`pnpm run typecheck` 运行同一张 `tsc -b` 图。 -- 两个聚合(`tsconfig.host.json`、`tsconfig.client.json`)以 `noEmit` 方式检查示例、测试和脚本,并通过 references 校验包/vendor 源码。 -- 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 -- 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。 +`pnpm run typecheck` 先执行 Host lib 阶段,以生成 Client 类型检查所需的 Remote 声明,再对 `tsconfig.client.json` 执行 `tsc -b`。两个 aggregate 本身以 `noEmit` 方式检查各自的示例、测试与脚本;被引用的 package/vendor project 保持与构建相同的发射行为。 复合项目将增量构建信息保存在各项目本地的 `lib/` 输出中。`pnpm run clean` 会根据根 TypeScript project-reference 图确定当前有效的输出目录,删除遗留的根目录构建信息,并删除已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。在删除现有目标前,该命令会解析目标父目录的真实路径;如果解析后的父目录位于仓库之外,则拒绝删除,防止使用符号链接的 project reference 将清理操作重定向到工作副本之外。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 @@ -49,14 +46,18 @@ Status: implemented ```sh pnpm run build: -tsc -b -tsdown +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web pnpm run verify-node-next-types: tsx scripts/verify-node-next-types.ts pnpm run typecheck: -tsc -b +pnpm run build:lib:host +tsc -b tsconfig.client.json pnpm run clean: tsx scripts/clean.ts @@ -75,8 +76,8 @@ tsx scripts/clean.ts 构建职责更加清晰: -- `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。 -- `build` 命令驱动根 solution 图。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责 `lib/index.*`。 +- `packages//` 和 `vendor/*` 下的每个普通模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。`api/remotes` 因生成契约顺序使用一个 solution 和两个互斥的 emitting project,是唯一例外。 +- `build` 命令按 Host 与 Client Project Reference 图执行。每个阶段都由 `tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责发布 runtime bundle。 - `lib/types/*.d.ts` 是发布用的声明输出;`.d.ts.map` 只作为本地编译产物保留。 - `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。 - `lib/types/*.js` 通常仅作为打包器输入。只有显式运行时 export 指向该输出树时,才会发布这些文件。 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml index d191386725..2a6cdcc2d0 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-22-tsconfig-solution-root-two-aggregates.md: 19c229693b98ff3825caf935fa647ab85aff0f56 -2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: becc43de1ef2f6a53b0f6c2285eb64d9b42604f1 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md +2026-07-22-tsconfig-solution-root-two-aggregates.md: 6e8d192b5fea1c7045ade6935fd0e2dc8434c502 +2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: 60581e3de55227a393e2528ce7d3c8fa1bf9c570 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md index 19c229693b..6e8d192b5f 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md @@ -27,7 +27,7 @@ One solution root, two check units, one shared base pair, no separate build or v The load-bearing principle: **cordis `Context` declaration-merge collisions exist only inside a `ts.Program`, never in module resolution.** A solution file forms no program, so referencing both aggregates from one root cannot collide the merges; vite-tsconfig-paths reads only `paths` and `include` and discards types, so one facade may span both sides. The only way to explode is to flatten both sides into a single program — hence two derived disciplines: `tsconfig.base.json` never gains `include`/`files` (it would leak into every extending package and narrow the facade), and every repo-wide `ts.Program` consumer (`scripts/ts-project.ts`, doc-typecheck standalone mode) seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly, never the root solution. Program-backed generators and semantic gates intentionally stay host-only; the client side gets program-backed gates only when a real need arrives. -Commands collapse to one graph and keep the config name explicit: `typecheck` = `tsc -b tsconfig.json`, `build` = `tsc -b tsconfig.json && tsdown`, lefthook pre-push stays `tsc -b tsconfig.json --pretty false` unchanged (the same line now covers both sides through the solution). `tsconfig.build.json` and `tsconfig.vitest.json` are deleted; all vitest configs point vite-tsconfig-paths at `tsconfig.base.json`. +The root `tsconfig.json` remains the solution entry for explicitly running the complete Project Reference graph, and lefthook pre-push incrementally covers both sides through `tsc -b tsconfig.json --pretty false`. Because the Client depends on Remote contracts generated by Host tsdown, the repository's `build` and `typecheck` commands run the Host and Client in order; the [API Remotes build note](2026-08-08-api-remotes-generated-contract-build.md) owns the exact orchestration. `tsconfig.build.json` and `tsconfig.vitest.json` are deleted; all vitest configs point vite-tsconfig-paths at `tsconfig.base.json`. The solution root `extends` the base deliberately: `examples/` and `scripts/` have no nearer tsconfig, so tsx (get-tsconfig) resolves their workspace imports through the root file. `extends` restores the `paths` map there while `files: []` keeps the file program-less. Their *type checking* is unaffected by this: examples, scripts, and website files are included by the host aggregate. @@ -41,5 +41,5 @@ The solution root `extends` the base deliberately: `examples/` and `scripts/` ha - `docs/development.md#typescript-project-layout` is the authoritative description; root `AGENTS.md` carries the two disciplines as conventions. - The [ts-build-config note](2026-06-17-ts-build-config.md) keeps ownership of the tsc-first build pipeline (tsc emits, tsdown bundles, `.ts` specifiers with `rewriteRelativeImportExtensions`); its former "one root typecheck project" shape is superseded by this note. -- Adding a package registers it in exactly one aggregate's references (host packages in `tsconfig.host.json`, client packages in `tsconfig.client.json`); the build graph needs no separate registration. -- The build gate depends on the typecheck gate: both now drive the same `tsc -b` graph, so running them concurrently would race the same `.tsbuildinfo` files. +- Adding an ordinary package registers it in exactly one aggregate's references: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`. `api/remotes` is the only explicit split exception because the Host generates a contract that the Client consumes later; its two concrete projects are registered separately, while its package-root solution enters neither aggregate. +- The Host and Client build phases must run serially: Client tsc cannot begin until Host tsdown has generated the contract. Each phase reuses its projects' incremental state instead of processing the same graph concurrently. diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md index becc43de1e..60581e3de5 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md @@ -27,7 +27,7 @@ GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[分层 整个方案立足的原则:**cordis `Context` 的声明合并冲突只存在于同一个 `ts.Program` 内部,从不发生在模块解析中。** solution 文件不构成 program,因此从一个根文件同时引用两个聚合不会让两侧的声明合并相撞;vite-tsconfig-paths 只读取 `paths` 与 `include`、丢弃全部类型信息,因此一个门面可以横跨两侧。唯一会爆炸的做法是把两侧压平进同一个 program,由此推出两条派生纪律:`tsconfig.base.json` 永远不得添加 `include`/`files`(否则会泄漏进每个继承它的包,并收窄门面范围);每个全仓级 `ts.Program` 消费方(`scripts/ts-project.ts`、doc-typecheck 独立模式)都显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子,绝不使用根 solution。基于 program 的生成器与语义门禁有意只留在宿主侧;客户端侧只有在真实需求出现时才引入基于 program 的门禁。 -各命令收敛到一张图,且显式写出配置名:`typecheck` = `tsc -b tsconfig.json`,`build` = `tsc -b tsconfig.json && tsdown`,lefthook pre-push 保持 `tsc -b tsconfig.json --pretty false` 不变(经由 solution,这同一行命令现已覆盖两侧)。`tsconfig.build.json` 与 `tsconfig.vitest.json` 删除;所有 vitest 配置都把 vite-tsconfig-paths 指向 `tsconfig.base.json`。 +根 `tsconfig.json` 仍是显式执行完整 Project Reference 图的 solution 入口,lefthook pre-push 通过 `tsc -b tsconfig.json --pretty false` 增量覆盖两侧。仓库的 `build` 与 `typecheck` 命令因 Client 依赖 Host tsdown 生成的 Remote 契约而按 Host、Client 顺序运行,具体编排由 [API Remotes 构建 Note](2026-08-08-api-remotes-generated-contract-build.md)负责。`tsconfig.build.json` 与 `tsconfig.vitest.json` 已删除;所有 vitest 配置都把 vite-tsconfig-paths 指向 `tsconfig.base.json`。 solution 根文件刻意 `extends` base:`examples/` 与 `scripts/` 没有更近的 tsconfig,tsx(get-tsconfig)通过根文件解析它们的 workspace 导入。`extends` 把 `paths` 映射带回根文件,`files: []` 则让它始终不构成 program。这不影响两者的*类型检查*:examples、scripts 与 website 的文件由宿主聚合纳入。 @@ -41,5 +41,5 @@ solution 根文件刻意 `extends` base:`examples/` 与 `scripts/` 没有更 - `docs/development.md#typescript-project-layout` 是权威描述;根 `AGENTS.md` 以约定形式收录上述两条纪律。 - [ts-build-config Agent Note](2026-06-17-ts-build-config.md) 继续拥有 tsc 先行的构建流水线(tsc 负责输出,tsdown 负责打包,`.ts` 说明符配合 `rewriteRelativeImportExtensions`);其原先「单一根类型检查项目」的形态由本文取代。 -- 新增一个包只登记进恰好一个聚合的 references(宿主包进 `tsconfig.host.json`,客户端包进 `tsconfig.client.json`);构建图无需另行登记。 -- 构建门禁依赖类型检查门禁:两者现在驱动同一张 `tsc -b` 图,并发运行会在同一批 `.tsbuildinfo` 文件上竞态。 +- 新增一个普通 package 只登记进恰好一个 aggregate 的 references(Host package 进 `tsconfig.host.json`,Client package 进 `tsconfig.client.json`)。`api/remotes` 因 Host 生成契约与 Client 消费契约的顺序关系成为唯一显式拆分例外;其两个具体 project 分别登记,包根 solution 不进入任一 aggregate。 +- Host 与 Client 构建阶段必须串行:Host tsdown 生成契约后 Client tsc 才能开始。各阶段复用各 project 的增量状态,不通过并发重复处理同一张图。 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml new file mode 100644 index 0000000000..8b1bbf8b4d --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md +2026-08-08-api-remotes-generated-contract-build.md: ac9bb445917e11a4b57280da513d36b0f434bbaf +2026-08-08-api-remotes-generated-contract-build.zh.md: 4f9760078c209a22b9e03837fd81769e156b5df9 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md new file mode 100644 index 0000000000..ac9bb44591 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md @@ -0,0 +1,80 @@ +# Agent Note: Ordered Build for API Remotes Generated Contracts + +Status: implemented + +English | [中文](2026-08-08-api-remotes-generated-contract-build.zh.md) + +## Problem + +TypeRT must generate `/remote` declarations and runtime contributions from the Host's `@Remote` methods before the Client's `api-remotes/src/client/index.ts` can typecheck and bundle those contributions. If the root build hands both the Host and Client Project Reference graphs to tsc together, the Client compiles before the generated artifacts exist. Adding a separate contracts preprocessing step would instead compile the generator again outside the normal Host graph and let stale artifacts hide incorrect dependencies. + +This ordering dependency must not change the repository's ordinary package rule. A normal package belongs to exactly one TypeScript face: Host packages are registered in `tsconfig.host.json`, and Client packages in `tsconfig.client.json`. A Client plugin having both a Node loader entry and a browser entry describes its bundled artifact shapes, not a reason to split its TypeScript project. + +## Decision + +The root build completes Host tsc and Host tsdown first, with Host tsdown running TypeRT and generating the Remote Client contract. It then completes Client tsc, Client tsdown, and the Web build: + +~~~text +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +Vite Web build +~~~ + +`build:lib:host` owns the first two steps, `build:lib:client` owns the middle two, and `build:web` runs last. `typecheck` must also run the complete Host lib phase first because Client tsc requires declarations generated by Host tsdown; it does not need Client tsdown or the Web build. + +Each tsc phase is the sole TypeScript compiler path and emits JavaScript, declarations, and incremental state to `lib/types`. Tsdown reads only that JavaScript and produces published bundles; it neither reads source nor emits declarations. + +## The sole package exception + +`api/remotes` is the only package with both Host and Client composite projects. The Host project contains the Agent/Session lookup policy, Host plugin entry, and invariant; the Client project contains only `src/client/index.ts`, which must wait for the generated contract: + +~~~text +packages/api/remotes/ +├─ tsconfig.json +├─ tsconfig.host.json +├─ tsconfig.client.json +└─ src/ + ├─ index.ts + ├─ agent-lookup.ts + ├─ invariant.ts + └─ client/ + └─ index.ts +~~~ + +The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and `host/apiproxy` reference `api/remotes/tsconfig.host.json`, while the root Client aggregate and `client/ui-goal` reference `api/remotes/tsconfig.client.json`. `ui-goal` itself remains an ordinary single Client project. + +The two projects use disjoint `files` and separate `.tsbuildinfo` files, so they can share `lib/types` without emitting any source file twice. If both sides later need a shared implementation, move that implementation into a neutral package instead of giving the same source to two emitting projects. + +This exception follows from the real generated-contract ordering and is not a template available to ordinary packages. New packages remain restricted to one aggregate; adding another exception requires changing this decision and proving another generated dependency that cannot be eliminated. + +## TypeRT and tsdown + +Host tsdown enables `typertPlugin({ mode: 'workspace', faces: ['host'] })` in the normal root config. The generator uses only `tsconfig.host.json` as its program seed and produces both `typert.host.*` and the `typert.remote-client.*` projection of Host contracts; Client tsdown neither starts TypeRT nor analyzes the Client aggregate. + +The TypeRT analyzer distinguishes compiler faces from runtime faces. Direct Project References in the aggregate determine which compiler face analyzes a project; only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. Runtime models follow package subpath contributions instead, so an ordinary single-project `dshClient` package may contribute both Host and Client runtime models. + +Both the Host and Client tsdown passes receive the same complete workspace of `vendor/*`, `packages/*/*`, and `apps/cli`. The root config does not scan `lib/types/client/index.js`, maintain a package classification table, or use a tsdown filter; package-local configs return entries for the current phase according to `DSH_BUILD_FACE`. + +An ordinary Client plugin returns an empty config during the Host pass and produces both its Node loader entry and browser bundle during the Client pass. The `clientBundle(..., { hostPhase: true })` used by `api-remotes` is the only phase exception: the Host pass produces its Host entry, and the Client pass produces only its browser bundle. Package-local tsdown without `DSH_BUILD_FACE` still returns that package's normal entries together for local single-package development. + +## Alternatives considered + +**Keep a separate contracts preprocessing step.** This would compile the generator again outside the normal Host Project Reference graph and let residual generated artifacts hide the Client entering the Host graph too early. + +**Run the root `tsc -b tsconfig.json` once before tsdown.** Client tsc would run before Host tsdown and could not obtain `/remote` declarations from a clean worktree. + +**Split every package containing `src/client/index.ts`.** Separate Node and browser entries are the normal Client plugin bundling convention and do not create a compilation ordering dependency; splitting them universally would only increase the maintenance cost of references and incremental state. + +**Scan Client compilation artifacts or maintain two workspace lists.** Artifact scanning would make package participation depend on residual files, while hand-maintained lists and package-name filters would drift as directories change. A complete workspace with package-local face selection already provides deterministic behavior. + +**Run TypeRT again during the Client pass.** Remote Client is a projection of the Host contract and has no independent Client reflection source; a second TypeRT program would only duplicate work and increase the risk of mixing both sides' declarations into one analysis. + +## Consequences + +A clean build is the authoritative check of ordering correctness: with no existing `/remote` artifacts, Host tsc must succeed first, Host tsdown must generate the contract, and then Client tsc, Client tsdown, and the Web build must succeed. No phase may write artifacts into `src`. + +The tsc-first ownership established by the [TypeScript build config note](2026-06-17-ts-build-config.md) remains unchanged, but this note replaces its command shape of one whole-graph tsc pass followed by bundling with ordered phases. The ordinary-package single-aggregate rule established by the [two-aggregate solution note](2026-07-22-tsconfig-solution-root-two-aggregates.md) also remains unchanged; this note creates one explicit exception for `api/remotes`. + +An independent Client build is no longer a self-contained entry on a clean worktree; repository commands, CI, and release flows must run the Host lib phase first. Developers of ordinary packages do not need to understand or copy this exception and continue to choose one aggregate according to the package's runtime environment. diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md new file mode 100644 index 0000000000..4f9760078c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md @@ -0,0 +1,80 @@ +# Agent Note: API Remotes 生成契约的有序构建 + +Status: implemented + +[English](2026-08-08-api-remotes-generated-contract-build.md) | 中文 + +## 问题 + +Host 的 `@Remote` 方法需要先由 TypeRT 生成 `/remote` 声明和运行时贡献,Client 的 `api-remotes/src/client/index.ts` 才能通过类型检查并打包这些贡献。若根构建先把 Host 与 Client 两张 Project Reference 图一起交给 tsc,Client 会在生成产物存在之前编译;若增加独立 contracts 预处理,又会让 generator 脱离正常 Host 图重复编译,并允许陈旧产物掩盖错误依赖。 + +该顺序依赖不能改变仓库的普通 package 规则。正常 package 只属于一个 TypeScript face:Host package 登记在 `tsconfig.host.json`,Client package 登记在 `tsconfig.client.json`。一个 Client plugin 同时具有 Node loader 入口与 browser 入口,只是打包产物形态,不是拆分 TypeScript project 的理由。 + +## 决策 + +根构建先完成 Host tsc 和 Host tsdown,由 Host tsdown 运行 TypeRT 并生成 Remote Client 契约;随后完成 Client tsc、Client tsdown 和 Web 构建: + +~~~text +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +Vite Web build +~~~ + +`build:lib:host` 负责前两步,`build:lib:client` 负责中间两步,`build:web` 最后运行。`typecheck` 也必须先执行完整 Host lib 阶段,因为 Client tsc 需要 Host tsdown 生成的声明;它不需要运行 Client tsdown 或 Web build。 + +每个 tsc 阶段都是唯一的 TypeScript 编译器路径,负责向 `lib/types` 发射 JavaScript、声明和增量状态。tsdown 只读取这些 JavaScript 并生成发布 bundle,不读取源码,也不生成声明。 + +## 唯一的 package 特例 + +`api/remotes` 是唯一同时拥有 Host 与 Client composite project 的 package。Host project 包含 Agent/Session lookup 策略、Host 插件入口和 invariant;Client project 只包含需要等待生成契约的 `src/client/index.ts`: + +~~~text +packages/api/remotes/ +├─ tsconfig.json +├─ tsconfig.host.json +├─ tsconfig.client.json +└─ src/ + ├─ index.ts + ├─ agent-lookup.ts + ├─ invariant.ts + └─ client/ + └─ index.ts +~~~ + +包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 `host/apiproxy` 引用 `api/remotes/tsconfig.host.json`;根 Client aggregate 与 `client/ui-goal` 引用 `api/remotes/tsconfig.client.json`。`ui-goal` 本身仍是普通的单一 Client project。 + +两个 project 使用互不重叠的 `files` 和不同的 `.tsbuildinfo`,因此可以共享 `lib/types` 而不重复发射任何源码。若未来需要两侧共用一份实现,应把实现移入中立 package,不能把同一源码同时交给两个 emitting project。 + +这个例外由生成契约的真实先后关系决定,不是可供普通 package 选择的模板。新增 package 仍只能登记进一个 aggregate;只有修改本决策并证明存在另一条不可消除的生成依赖,才能增加例外。 + +## TypeRT 与 tsdown + +Host tsdown 在普通根配置中启用 `typertPlugin({ mode: 'workspace', faces: ['host'] })`。generator 只以 `tsconfig.host.json` 为 program 种子,生成 `typert.host.*` 以及 Host 契约投影出的 `typert.remote-client.*`;Client tsdown 不启动 TypeRT,也不分析 Client aggregate。 + +TypeScript compiler face 与 TypeRT 运行时产物 face 是两层概念。普通 `dshClient` package 即使只有一个 compiler project,也可以按公开 subpath 同时贡献 Host 与 Client 运行时模型;aggregate 显式引用 `tsconfig.host.json` 或 `tsconfig.client.json` 时,analyzer 才把该 project 限定到对应 face。因此 `api-remotes` 的 Host 分析不会顺带注册其 Client 入口,普通双入口 package 的 Host 模型也不会丢失。 + +Host 与 Client 两次 tsdown 都接收 `vendor/*`、`packages/*/*` 和 `apps/cli` 这组完整 workspace。根配置不扫描 `lib/types/client/index.js`,不维护 package 分类表,也不使用 tsdown filter;包内配置根据 `DSH_BUILD_FACE` 返回本阶段入口。 + +普通 Client plugin 在 Host pass 返回空配置,在 Client pass 同时生成 Node loader 入口与 browser bundle。`api-remotes` 的 `clientBundle(..., { hostPhase: true })` 是唯一阶段例外:Host pass 生成其 Host 入口,Client pass 只生成 browser bundle。未指定 `DSH_BUILD_FACE` 的 package-local tsdown 仍同时返回该 package 的正常入口,供本地单包开发使用。 + +## 考虑过的替代方案 + +**保留独立 contracts 预处理。** 这会在正常 Host Project Reference 图之外额外编译 generator,并让残留生成物掩盖 Client 过早进入 Host 图的问题。 + +**一次执行根 `tsc -b tsconfig.json` 后再运行 tsdown。** Client tsc 在 Host tsdown 之前发生,无法从干净工作树获得 `/remote` 声明。 + +**拆分所有包含 `src/client/index.ts` 的 package。** Node 与 browser 双入口是普通 Client plugin 的打包约定,不形成编译顺序依赖;普遍拆分只会增加 references 和增量状态的维护成本。 + +**扫描 Client 编译产物或维护两份 workspace 清单。** 产物扫描会让 package 是否参与构建取决于残留文件,手工清单和 package 名过滤则会随目录调整产生漂移。完整 workspace 加包内 face 选择已经提供确定行为。 + +**在 Client pass 再运行 TypeRT。** Remote Client 是 Host 契约的投影,没有独立 Client 反射源;第二个 TypeRT program 只会重复工作并增加两侧声明混入同一分析的风险。 + +## 后果 + +干净构建成为顺序正确性的权威验证:没有任何既存 `/remote` 产物时,Host tsc 必须先成功,Host tsdown 必须生成契约,随后 Client tsc、Client tsdown 与 Web build 必须成功。任何阶段都不得把产物写进 `src`。 + +[TypeScript 构建配置 Note](2026-06-17-ts-build-config.md)确定的 tsc-first 职责保持不变,但其单次全图 tsc 后再打包的命令形态由本文的有序阶段取代。[双 aggregate solution Note](2026-07-22-tsconfig-solution-root-two-aggregates.md)确定的普通 package 单 aggregate 规则保持不变,本文只为 `api/remotes` 建立一个显式例外。 + +Client 的独立构建不再是干净工作树上的自足入口;仓库命令、CI 和发布流程必须先运行 Host lib 阶段。普通 package 的开发者无需理解或复制该例外,仍按所属运行环境选择一个 aggregate。 diff --git a/AGENTS.md b/AGENTS.md index c265d3cf32..d77f5c5b16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **Trust TypeScript at typed same-process seams.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries. - **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). -- **`ts.Program` consumers seed `tsconfig.host.json` or `tsconfig.client.json`, never the root solution** — one program holding both sides collides the cordis `Context` merges ([layout](docs/development.md#typescript-project-layout)). +- **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 074644ff3e..6bf3151311 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 33dfb30c9da25e46b660a3fa54ef37f587cbda08 -api-gateway.zh.md: 633eb10c0f2f065ecf27545813cc17d79f391865 +api-gateway.md: 7d5c5b7e46a66b2bf56ee1a1bbd57e7758a4c520 +api-gateway.zh.md: cbf62258b7bf4a1d2f657cf1fc08a8dbc0a1a939 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 33dfb30c9d..7d5c5b7e46 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -94,7 +94,11 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p ## Strict generation pipeline -The root build orders `build:lib:host`, `build:lib:client`, and `build:web`. The Host lib build first runs `build:lib:contracts`: it compiles the TypeRT generator, then starts a Host `ts.Program` through `tsdown.typert-host.config.ts` with `tsconfig.host.json` as its seed. The generator does not put the Host and Client aggregates in the same program, so it does not trigger conflicts between the two Cordis `Context` declaration merges. +The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the TypeRT generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting TypeRT again. + +Both tsdown passes receive the complete workspace and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. + +`api-remotes` is the only package with split TypeScript faces. Its Host project owns the Agent/Session lookup policy, while its Client project depends on `/remote` declarations generated for business packages during Host tsdown; root aggregates and direct consumers must reference `api/remotes/tsconfig.host.json` or `api/remotes/tsconfig.client.json` respectively. The package's `clientBundle(..., { hostPhase: true })` produces its Host entry during Host tsdown and leaves only the browser entry for Client tsdown. Every other package remains registered in one aggregate. Each contributing business package writes generated files to its own `lib/` directory, not to its source directory: @@ -149,13 +153,13 @@ pnpm run dev:web `dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dshClient` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. -Changing only a Remote method's implementation body without changing its contract does not require regenerating the TypeRT files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, regenerate the strict contracts before the Client bundle consumes the new artifacts: +Changing only a Remote method's implementation body without changing its contract does not require regenerating the TypeRT files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution: ```sh -pnpm run build:lib:contracts +pnpm run build:lib ``` -The running Client watcher consumes these generated files when it rebundles; without a watcher, run `pnpm run build:lib:client`. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` includes `build:lib:contracts` as a prerequisite, and CI and release builds also use the strict generation pipeline. +The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` runs the Host lib phase before Client tsc, and CI and release builds use the same order. ## Boundaries diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 633eb10c0f..cbf62258b7 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -94,7 +94,11 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 ## 严格生成链路 -根构建按 `build:lib:host`、`build:lib:client`、`build:web` 排序。Host lib 构建首先运行 `build:lib:contracts`:它先编译 TypeRT generator,再通过 `tsdown.typert-host.config.ts` 以 `tsconfig.host.json` 为种子启动 Host `ts.Program`。生成器不会把 Host 与 Client 聚合放入同一个 program,因而不会触发两侧 Cordis `Context` 声明合并冲突。 +根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;TypeRT generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 TypeRT。 + +两次 tsdown 都接收完整 workspace,且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按 package 名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client plugin 在 Client 阶段一起生成 Node loader 入口与 browser bundle。 + +`api-remotes` 是唯一拆分 TypeScript face 的 package 特例。它的 Host project 负责 Agent/Session lookup 策略,Client project 则依赖业务包在 Host tsdown 中生成的 `/remote` 声明;根 aggregate 与直接消费方必须分别引用 `api/remotes/tsconfig.host.json` 或 `api/remotes/tsconfig.client.json`。包内 `clientBundle(..., { hostPhase: true })` 让 Host 入口在 Host tsdown 中生成,让 Client tsdown 只生成 browser 入口。其他 package 仍只登记在一个 aggregate 中。 每个贡献业务包把生成文件写入自己的 `lib/`,而不是源码目录: @@ -149,13 +153,13 @@ pnpm run dev:web `dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dshClient` 声明的 Client plugin 并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 -只修改 Remote 方法实现体而不改变契约时,无需重新生成 TypeRT 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,先重新生成严格契约,再让 Client bundle 使用新的产物: +只修改 Remote 方法实现体而不改变契约时,无需重新生成 TypeRT 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格契约,再让 Client 编译并打包新的贡献: ```sh -pnpm run build:lib:contracts +pnpm run build:lib ``` -运行中的 Client watcher 会在重新打包时消费这些生成文件;没有 watcher 时运行 `pnpm run build:lib:client`。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 自带 `build:lib:contracts` 前置步骤,CI 与发布构建也使用严格生成链路。 +运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 契约,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 会执行 Host lib 阶段后再运行 Client tsc,CI 与发布构建也使用同一顺序。 ## 边界 diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 85c1af757b..0c26b8be17 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: a45b222f6aed905a18ef9b480c989e6045029afe -adding-a-package.zh.md: af0e4d0779fa99ce43ebccba00c33eab16c4d362 +adding-a-package.md: 8ab603ea7b235bd2a582c9232afaca281a969448 +adding-a-package.zh.md: 8c0bc9dbd02b18a388f8e6ce91af10753c0210a2 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index a45b222f6a..8ab603ea7b 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -31,7 +31,7 @@ In-package relative imports use explicit `.ts` specifiers in source (for example | File | Change | |---|---| | `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages//*/src` candidate to the `@deepseek-ai/dsh-*` wildcard | -| `tsconfig.host.json` (host-side package) or `tsconfig.client.json` (client-side package) | add `{ "path": "./packages//" }` to `references` — exactly one aggregate, never both ([layout](../development.md#typescript-project-layout)) | +| `tsconfig.host.json` (Host package) or `tsconfig.client.json` (Client package) | add `{ "path": "./packages//" }` to `references` — an ordinary package belongs to exactly one aggregate, never both. `api/remotes` uses a repository-specific split because the Host generates a contract that the Client consumes in a later phase; new packages must not copy it ([layout](../development.md#typescript-project-layout)) | | `knip.json` | only if the package has entrypoints that repository discovery does not already cover | A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index af0e4d0779..8c0bc9dbd0 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -31,7 +31,7 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c | 文件 | 变更 | |---|---| | `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages//*/src` 候选路径 | -| `tsconfig.host.json`(host 侧包)或 `tsconfig.client.json`(client 侧包) | 在 `references` 中添加 `{ "path": "./packages//" }`——恰好一个聚合,绝不两个都加([布局](../development.md#typescript-project-layout)) | +| `tsconfig.host.json`(Host 包)或 `tsconfig.client.json`(Client 包) | 在 `references` 中添加 `{ "path": "./packages//" }`——普通包恰好属于一个 aggregate,绝不两个都加。`api/remotes` 因 Host 生成契约与 Client 消费契约之间存在顺序依赖而使用仓库专属拆分,新增包不得仿照([布局](../development.md#typescript-project-layout)) | | `knip.json` | 仅当包有仓库发现机制尚未覆盖的入口时需要 | `packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b66552b175..5a1024e657 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: b7ecab3536d739c105f11a640a07ea83a22f4398 -development.zh.md: 33ceba9f05c45c06acae7c83425a30c5e26ca433 +development.md: acf279d182ca580c6e372be6fbdca8f46afdc445 +development.zh.md: 927c72be2de78f9e7f67565b9524db85c5aa1669 diff --git a/docs/development.md b/docs/development.md index b7ecab3536..acf279d182 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,24 +43,39 @@ Setup is complete when `pnpm run typecheck` exits successfully. ### TypeScript project layout -The repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates. - -The repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them. +The repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`. | File | Role | Forms a program? | |---|---|---| -| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No | -| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes | -| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes | +| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No | +| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes | +| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes | | `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | -| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No | +| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | -Host and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow: +Host and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow: - `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope. -- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need. +- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. +- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). +`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary. + +The root build follows the generated dependency order: + +```sh +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web +``` + +Both tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. + +TypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. + +Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology and the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership. Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. diff --git a/docs/development.zh.md b/docs/development.zh.md index 33ceba9f05..927c72be2d 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -43,24 +43,39 @@ pnpm run typecheck ### TypeScript 项目布局 -仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。 - -仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。 +仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。 | 文件 | 角色 | 是否构成 program? | |---|---|---| -| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 | -| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 | -| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 | +| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 | +| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 | +| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 | | `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | -| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 | +| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 | -host 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律: +Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律: - `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。 -- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。 +- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 +- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。 -静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 +`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。 + +根构建按生成依赖排序: + +```sh +tsc -b tsconfig.host.json +tsdown --env.DSH_BUILD_FACE host +tsc -b tsconfig.client.json +tsdown --env.DSH_BUILD_FACE client +pnpm run build:web +``` + +两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。 + +TypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成契约构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。 + +静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 diff --git a/docs/module-graph.md b/docs/module-graph.md index d963273363..ab9fc97a71 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -328,6 +328,9 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants + pkg_client_runtime --> pkg_invariants + pkg_client_runtime --> pkg_type_meta + pkg_client_runtime --> pkg_typert_registry pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -376,6 +379,29 @@ flowchart TD pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants + pkg_client_ui_models --> pkg_client_connection + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_schema_form + pkg_client_ui_models --> pkg_client_ui_primitives + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_client_web_react + pkg_client_ui_models --> pkg_invariants + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_ui_primitives + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_environment @@ -426,6 +452,36 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -521,6 +577,10 @@ flowchart TD pkg_headless --> pkg_host_webserver pkg_headless --> pkg_invariants pkg_headless --> pkg_session + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -528,6 +588,16 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -625,9 +695,20 @@ flowchart TD pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_token_meter pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -781,10 +862,35 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction - pkg_client_runtime --> pkg_api_remotes - pkg_client_runtime --> pkg_invariants - pkg_client_runtime --> pkg_type_meta - pkg_client_runtime --> pkg_typert_registry + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_conversation + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -916,29 +1022,42 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_locale --> pkg_client_runtime - pkg_client_locale --> pkg_client_ui_primitives - pkg_client_locale --> pkg_client_ui_slots - pkg_client_locale --> pkg_invariants - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_models --> pkg_client_connection - pkg_client_ui_models --> pkg_client_runtime - pkg_client_ui_models --> pkg_client_schema_form - pkg_client_ui_models --> pkg_client_ui_primitives - pkg_client_ui_models --> pkg_client_ui_slots - pkg_client_ui_models --> pkg_client_web_react - pkg_client_ui_models --> pkg_invariants - pkg_client_ui_settings --> pkg_client_runtime - pkg_client_ui_settings --> pkg_client_ui_primitives - pkg_client_ui_settings --> pkg_client_ui_slots - pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -981,36 +1100,6 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1044,17 +1133,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_token_meter - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1075,85 +1153,6 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_locale - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_client_ui_slots - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_client_ui_primitives - pkg_client_ui_goal --> pkg_client_ui_slots - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_conversation - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission ``` | Package | Group | Depends on | @@ -1191,6 +1190,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | @@ -1207,6 +1207,11 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1221,6 +1226,12 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1244,8 +1255,11 @@ flowchart TD | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | @@ -1266,7 +1280,9 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`typert-registry`](../packages/typert/registry) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1292,7 +1308,10 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1314,11 +1333,10 @@ flowchart TD | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | -| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1326,27 +1344,8 @@ flowchart TD | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 0e8c62841e..58db577ee5 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md Naming notes: -- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)). +- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `support/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. - A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 82947331c5..f8f7a6400b 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md -README.md: 7f6a2114d900413d972584c0f1c141b7f835ba36 -README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5 +README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a +README.zh.md: 7490d68781d3a7b0002b73fe06056ec86c144575 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 7f6a2114d9..3d9de0955f 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -10,6 +10,14 @@ The current Client assembly mounts only the Goal Remote contribution. Cordis eff This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. +## Build boundary + +An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. + +This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory. + +The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`. + ## Model Experience None, as this BFF selects Remote application methods and identity policy but registers no model surface. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index cce263747d..7490d68781 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -10,6 +10,14 @@ 本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。 +## 构建边界 + +仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 + +本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。 + +包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。 + ## 模型体验 无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。 diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index cd6588c0ab..583835a63b 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/generator/README.md -README.md: c343fd9475a9407159037f0a10e3a0586a77c3da -README.zh.md: f9f863fe67d256b9744d7caeda0680f715808714 +README.md: 38030c2b7e07c70ab79001086640b6581943dbd9 +README.zh.md: afa45820b7c7fba77704b86902c8752cd45777e6 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index c343fd9475..38030c2b7e 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) TypeScript project analyzer and model-driven Typert generator. It converts the developer-authored source type tree into compiler-independent `FaceModel` and `TypeGraph` data before any artifact is rendered. Static analysis can consume that model without Cordis; emitters never receive TypeScript AST or checker objects. -Host and client use independent `ts.Program` instances seeded from `tsconfig.host.json` and `tsconfig.client.json`. Direct project references establish face membership, `package.json#exports` establishes every cross-package public boundary, and source imports or re-exports are the only allowed cross-face edges. Types owned by NPM dependencies, including global declarations from `@types` packages, remain `external` references instead of being expanded. +The analyzer can use independent `ts.Program` instances seeded from `tsconfig.host.json` or `tsconfig.client.json`. Direct project references establish compiler-face membership, while package subpaths establish TypeRT runtime-face contributions: an ordinary single-project `dshClient` package may contribute both Host and Client runtime models, and only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. `package.json#exports` establishes every cross-package public boundary, and source imports or re-exports are the only allowed cross-face edges. Types owned by NPM dependencies, including global declarations from `@types` packages, remain `external` references instead of being expanded. ## Analysis Model @@ -18,7 +18,7 @@ Each face contains package exports, Cordis services and events, explicitly tagge `WorkspaceTypertGenerator` discovers contributors by walking package public exports reachable from Cordis `Context` or `Events` augmentations and explicit `@typert` declarations. When invoked for artifact publication, it requires host artifacts at `lib/typert.host.{js,d.ts}` exposed as `package/typert`, and client artifacts at `lib/typert.client.{js,d.ts}` exposed as `package/client/typert`. Generated declarations expose `TYPERT` as `unknown`, so contributing business packages do not depend on the runtime registry. -Publication is package opt-in. The root build and typecheck do not generate Typert artifacts or require every business package to add Typert exports. Static consumers can call `WorkspaceAnalyzer` directly, select host/client and package subsets, and use bounded package batches without publishing or loading runtime artifacts. +Publication is package opt-in, and business packages without the corresponding public entry do not need Typert artifacts. The repository's Host tsdown runs workspace TypeRT generation with `tsconfig.host.json` as its only program seed; it produces both Host reflection artifacts and the `typert.remote-client.*` projection of Host Remote contracts for the Client. The subsequent Client tsdown neither starts TypeRT nor analyzes `tsconfig.client.json`. Static consumers can still call `WorkspaceAnalyzer` directly, explicitly select a face and package subset, and process packages in batches without publishing or loading runtime artifacts. ## Repository-specific Cordis projection diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index f9f863fe67..afa45820b7 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -4,7 +4,7 @@ TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何产物之前,它会先将开发者编写的源类型树转换为独立于编译器的 `FaceModel` 和 `TypeGraph` 数据。静态分析无需 Cordis 即可消费该模型;各产物生成组件均不会接收 TypeScript 抽象语法树(AST)或类型检查器对象。 -宿主侧与客户端侧分别使用独立的 `ts.Program` 实例,二者以 `tsconfig.host.json` 和 `tsconfig.client.json` 初始化。直接项目引用确定各包所属的 face,`package.json#exports` 确定所有跨包公开边界,跨 face 的边则只能来自源码中的导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。 +分析器可以分别使用由 `tsconfig.host.json` 或 `tsconfig.client.json` 初始化的独立 `ts.Program`。直接 Project Reference 确定 compiler project 成员关系;带 `dshClient` 的普通单 project package 可按公开 subpath 同时贡献 Host 与 Client 运行时 face,显式引用 `tsconfig.host.json` 或 `tsconfig.client.json` 的拆分 project 则只贡献所选 face。`package.json#exports` 确定所有跨包公开边界,跨 face 的边只能来自源码导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。 ## 分析模型 @@ -18,7 +18,7 @@ TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何 `WorkspaceTypertGenerator` 会遍历从 Cordis `Context` 或 `Events` 扩充声明及显式 `@typert` 声明可达的包公开导出,以发现贡献方。发布产物时,它要求宿主侧产物位于 `lib/typert.host.{js,d.ts}` 并以 `package/typert` 暴露,客户端侧产物位于 `lib/typert.client.{js,d.ts}` 并以 `package/client/typert` 暴露。生成的声明将 `TYPERT` 暴露为 `unknown`,因此参与贡献的业务包无需依赖运行时注册表。 -各包可自行选择是否发布。根目录的构建和类型检查不会生成 Typert 产物,也不要求每个业务包添加 Typert 导出。静态消费方可以直接调用 `WorkspaceAnalyzer`,选择宿主侧/客户端侧及包子集,并在不发布或加载运行时产物的情况下分批处理包,同时限制每批数量。 +各包可自行选择是否发布,未提供对应公开入口的业务包无需生成 Typert 产物。仓库的 Host tsdown 会以 `tsconfig.host.json` 为唯一 program 种子运行 workspace TypeRT 生成;它既生成 Host 反射产物,也把 Host Remote 契约投影为 Client 使用的 `typert.remote-client.*`。后续 Client tsdown 不启动 TypeRT,也不分析 `tsconfig.client.json`。静态消费方仍可直接调用 `WorkspaceAnalyzer`,显式选择 face 与包子集,并在不发布或加载运行时产物的情况下分批处理包。 ## 本仓库的 Cordis 投影 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 91509f3267..b2adbc38aa 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology and the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成契约构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From 69bd00ae76f0cc83b2f3837955cf4463cf53bfd8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 10:28:30 +0800 Subject: [PATCH 033/100] chore(web): register the skill-user-invoke scenario in both typecheck planes The web app project excludes every e2e file (they are host-plane programs) and tsconfig.host.json includes them one by one; the new scenario joins both lists so it keeps typecheck coverage without dragging host sources into the client project. --- apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 528714a527..41224d21e4 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -57,6 +57,7 @@ "tests/markdown-inline-code-links.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", + "tests/skill-user-invoke.e2e.ts", "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index 6884839536..9a06566fa2 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -44,6 +44,7 @@ "apps/web/tests/markdown-inline-code-links.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", + "apps/web/tests/skill-user-invoke.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", "apps/web/tests/access-confirmation.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", From ae9d31d098f473fe3ed369043c5d9fedc0e8839d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 10:46:58 +0800 Subject: [PATCH 034/100] review: pin off-value wire contract, scope compat inheritance to the entry's api, update the superseded note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on #1977, each verified before acting: the 2026-08-03 declared-provider-catalog note is updated in place and cross-linked both ways now that reasoningEfforts/compat reopened half of its rejected alternative; resolveModelCompat inherits the catalog entry's compat only while the resolved api still is the entry's own, so a route-level api repoint no longer merges another protocol's shape as a completions base; the off-with-value promise gains a request-boundary test proving pi-ai reads thinkingLevelMap.off when the reasoning option is absent (and the catalog-level test name stops overclaiming); the cannot-stop-thinking wording narrows to what is actually enforced (no Off offered, explicit Off refused — an effortless request goes out bare); the z.const(null) comment attributes null passthrough to schemastery's nullable short-circuit; the baseten drift-gate claim names its verification source; and the layered-merge delete gap for dict keys is documented under Known Limitations with the atomic-leaf follow-up in #2003. --- ...-pi-ai-declared-provider-catalog.i18n.yaml | 4 +-- ...6-08-03-pi-ai-declared-provider-catalog.md | 6 ++-- ...8-03-pi-ai-declared-provider-catalog.zh.md | 6 ++-- ...per-model-reasoning-declarations.i18n.yaml | 4 +-- ...-pi-ai-per-model-reasoning-declarations.md | 6 ++-- ...-ai-per-model-reasoning-declarations.zh.md | 6 ++-- docs/user/guide/providers.i18n.yaml | 4 +-- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +-- packages/llm/llm-pi-ai/README.md | 3 +- packages/llm/llm-pi-ai/README.zh.md | 3 +- packages/llm/llm-pi-ai/src/catalog.ts | 10 ++++-- packages/llm/llm-pi-ai/src/config.ts | 14 ++++---- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 33 +++++++++++++++++++ packages/llm/llm-pi-ai/tests/catalog.spec.ts | 2 +- 16 files changed, 75 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml index 9300571e28..2969995da6 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md -2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41 -2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f +2026-08-03-pi-ai-declared-provider-catalog.md: f908eb6293b77680193fcd8f7be7a9089477855a +2026-08-03-pi-ai-declared-provider-catalog.zh.md: ce91abd6dc71f790c72766cd3f819096d596182c diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md index d75b6bdb91..f908eb6293 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -14,7 +14,7 @@ The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/com A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it: -- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. +- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — at this note's writing `id`, `name`, `contextWindow`, `maxTokens`; [[2026-08-08-pi-ai-per-model-reasoning-declarations]] later added `reasoningEfforts` and `compat`, which is also where the original "reasoning rides the installed entry or is absent" stance was revisited (a bare capability flag stays rejected; a full per-level declaration with wire spellings does not have its problem). Pricing and input modalities remain absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Materialization spreads the installed entry and overrides the configured fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. - `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. - `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`. - A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked. @@ -35,7 +35,7 @@ The configurable-provider directory is now the installed catalog **joined with** pi-ai reports a model with no reasoning metadata as supporting the single level `off`, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: `off` becomes an *omitted* reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows `off` selected. -`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. +`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model whose entry declares no `reasoningEfforts` ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] made declared efforts carry that metadata) **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. ### Credentials stay outside pi-ai @@ -50,7 +50,7 @@ A route's auth follows from that. A catalog route keeps the installed provider's - **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports. - **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations. - **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working. -- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported. +- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer read those fields then, so a configured price or modality would change nothing while reading as supported. The consumer-driven half of this arrived later: [[2026-08-08-pi-ai-per-model-reasoning-declarations]] opened reasoning (as `reasoningEfforts`, not a raw `thinkingLevelMap`) and the two reasoning-dispatch `compat` switches once selectors and dispatch actually consumed them; cost and modalities stay closed for the original reason. - **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it. - **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md index f8dba9900b..ce91abd6dc 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -14,7 +14,7 @@ Status: implemented 提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`: -- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 +- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——本 note 写就时为 `id`、`name`、`contextWindow`、`maxTokens`;[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 之后加入了 `reasoningEfforts` 与 `compat`,当初「推理沿用已安装条目或直接缺席」的立场也在那里被重新审视(孤立的能力布尔量仍被拒绝;带 wire 拼写的逐档位完整声明没有它那个问题)。定价与输入模态仍不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。物化时以已安装条目铺底、再覆盖已配置的字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 - `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。 - `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。 - 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。 @@ -35,7 +35,7 @@ Status: implemented pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:`off` 在派发时变成被*省略*的 reasoning 选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 `off` 已选中。 -因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖每一个手工声明的模型**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 +因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖条目未声明 `reasoningEfforts` 的每一个手工声明模型([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 让声明的档位携带这份元数据)**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 ### 凭据留在 pi-ai 之外 @@ -50,7 +50,7 @@ pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `Cred - **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider` 的 `auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。 - **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。 - **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。 -- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。 +- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当时没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。这条否决里由消费方驱动的那一半后来兑现了:[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 在选择器与分派真正消费之后开放了推理(以 `reasoningEfforts` 的形态,而非裸 `thinkingLevelMap`)和两个推理分派 `compat` 开关;成本与模态仍因原有理由保持关闭。 - **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。 - **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。 diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml index 3b448f4cf1..3639c8da6b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md -2026-08-08-pi-ai-per-model-reasoning-declarations.md: 436b5f3f9f30c1bb1dc5816b12ce1596c5d01ec8 -2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 47b34dfd270f90fef2802a00e3632777d5636a73 +2026-08-08-pi-ai-per-model-reasoning-declarations.md: b6264feeb724e3693078fa3fc3e3fc16ed01aacb +2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 1b30f7e0c42974c777a535e133a47caa217e2e5e diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md index 436b5f3f9f..b6264feeb7 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md @@ -6,15 +6,15 @@ English | [中文](2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md) ## Problem -A hand-declared pi-ai route's models materialized with `reasoning: false`, so `getSupportedThinkingLevels` short-circuited to `["off"]`: the composer offered no effort picker for them, and the route-level `reasoning` default — the only reasoning knob a profile had — made every request to such a model fail with `UNSUPPORTED_REASONING_EFFORT` before network I/O. The same route-level knob was also the wrong altitude for catalog routes: one provider's models disagree about which levels they accept (deepseek ships `[off, high, max]` beside catalog models with `xhigh`), so a single per-route level could not be set without breaking part of the route, which is why the Models page stopped writing it entirely (#1860) and left `settings.yaml` with no way to align efforts per model. +Under the declared-provider catalog ([[2026-08-03-pi-ai-declared-provider-catalog]], which deliberately kept reasoning out of the configurable fields), a hand-declared pi-ai route's models materialized with `reasoning: false`, so `getSupportedThinkingLevels` short-circuited to `["off"]`: the composer offered no effort picker for them, and the route-level `reasoning` default — the only reasoning knob a profile had — made every request to such a model fail with `UNSUPPORTED_REASONING_EFFORT` before network I/O. The same route-level knob was also the wrong altitude for catalog routes: one provider's models disagree about which levels they accept (deepseek ships `[off, high, max]` beside catalog models with `xhigh`), so a single per-route level could not be set without breaking part of the route, which is why the Models page stopped writing it entirely (#1860) and left `settings.yaml` with no way to align efforts per model. Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (`compat.thinkingFormat`, `compat.supportsReasoningEffort`) by recognizing the endpoint URL, and a private gateway's URL says nothing — a DeepSeek-dialect gateway was spoken to in the OpenAI dialect with no configuration that could correct it. And the only way to touch one catalog model was the `models` list, which *replaces* the served catalog: narrowing `gpt-5`'s levels meant restating all thirty-eight openai models or silently dropping thirty-seven. ## Decision -`PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, thinking cannot be turned off; declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. +`PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, no Off is offered and an explicit Off request is refused (an effortless request still goes out bare, leaving the provider its default); declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. -`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so the pi-ai upgrade that adds a format (0.84 added `baseten`) fails compilation until the new member is classified. +`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). `modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model). diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md index 47b34dfd27..1b30f7e0c4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -手工声明的 pi-ai 路由,其模型物化出来就带着 `reasoning: false`,于是 `getSupportedThinkingLevels` 短路成 `["off"]`:输入框不为它们提供档位选择器,而路由级的 `reasoning` 默认值——当时 profile 仅有的推理旋钮——让发往这类模型的每个请求都在网络 I/O 之前以 `UNSUPPORTED_REASONING_EFFORT` 失败。同一个路由级旋钮对 catalog 路由来说也放错了层级:同一提供方下各模型接受的档位并不一致(deepseek 自带 `[off, high, max]`,旁边就是带 `xhigh` 的 catalog 模型),单个路由级档位怎么设都会弄坏路由的一部分——这正是模型页彻底停写它的原因(#1860),而 `settings.yaml` 也因此没有了任何按模型对齐档位的办法。 +在声明式提供方 catalog([[2026-08-03-pi-ai-declared-provider-catalog]],它刻意把推理排除在可配置字段之外)之下,手工声明的 pi-ai 路由,其模型物化出来就带着 `reasoning: false`,于是 `getSupportedThinkingLevels` 短路成 `["off"]`:输入框不为它们提供档位选择器,而路由级的 `reasoning` 默认值——当时 profile 仅有的推理旋钮——让发往这类模型的每个请求都在网络 I/O 之前以 `UNSUPPORTED_REASONING_EFFORT` 失败。同一个路由级旋钮对 catalog 路由来说也放错了层级:同一提供方下各模型接受的档位并不一致(deepseek 自带 `[off, high, max]`,旁边就是带 `xhigh` 的 catalog 模型),单个路由级档位怎么设都会弄坏路由的一部分——这正是模型页彻底停写它的原因(#1860),而 `settings.yaml` 也因此没有了任何按模型对齐档位的办法。 两个相邻的缺口让问题雪上加霜。pi-ai 靠识别端点 URL 来决定推理的*协议方言*(`compat.thinkingFormat`、`compat.supportsReasoningEffort`),而私有网关的 URL 什么也说明不了——说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且没有任何配置能更正它。另外,想动单个 catalog 模型,唯一的手段是 `models` 列表,而它会*替换*所服务的 catalog:收窄 `gpt-5` 的档位,意味着要么重述全部三十八个 openai 模型,要么静默丢掉三十七个。 ## 决策 -`PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,思考就关不掉;声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 +`PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,选择器不提供 Off,显式请求 Off 会被拒绝(不点名档位的请求仍会不带参数地发出,提供方保留自己的默认行为);声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 -`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级(0.84 加入了 `baseten`)会编译失败,直到新成员被归类。 +`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。 `modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 787f3ec1d6..38df7fb986 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: c2c578b8489621004d5ceab8330f63b4e371b1f6 -providers.zh.md: df50cdd39321b7267089ca12a68a42696f7f8f66 +providers.md: 8b52044e64411e3081d56c1ee1849d0b24cd1cda +providers.zh.md: f4a42a4093d253b4b230e4a838ba275a0ce58ac9 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index c2c578b848..8b52044e64 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -106,7 +106,7 @@ Reshaping a few catalog models while keeping the rest is `modelOverrides`' job: The configurable model fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no consumer and ride the installed entry. -**Declare reasoning levels per model.** `reasoningEfforts` lists the levels a model offers: each key appears in the composer's effort picker, and its value is what dispatch sends on the wire — `high: high` passes the name through, `max: ultra` renames it for a gateway with its own vocabulary. A level you leave out is not offered. `off` is special: declared without a value, Off appears in the picker and selecting it sends nothing; left out entirely, the model cannot stop thinking. `reasoningEfforts: false` declares a non-reasoning model, which is also how you strip reasoning from a catalog model your gateway cannot serve. Without this field a custom model does not reason and a catalog model keeps its catalog levels. +**Declare reasoning levels per model.** `reasoningEfforts` lists the levels a model offers: each key appears in the composer's effort picker, and its value is what dispatch sends on the wire — `high: high` passes the name through, `max: ultra` renames it for a gateway with its own vocabulary. A level you leave out is not offered. `off` is special: declared without a value, Off appears in the picker and selecting it sends nothing; left out entirely, the picker offers no Off and requests carry no off switch — the provider's own default decides. `reasoningEfforts: false` declares a non-reasoning model, which is also how you strip reasoning from a catalog model your gateway cannot serve. Without this field a custom model does not reason and a catalog model keeps its catalog levels. **Pick the reasoning dialect.** How a level travels — plain `reasoning_effort`, DeepSeek's `thinking: {type}` plus effort, and so on — is normally guessed from the endpoint URL, and a private gateway's URL says nothing, so a DeepSeek-style gateway would be spoken to in the OpenAI dialect. `compat.thinkingFormat` sets the dialect explicitly, and `compat.supportsReasoningEffort: false` holds the parameter back from an endpoint that rejects it; both work on the route (its models' default) or per model, for `openai-completions` routes only. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index df50cdd393..f4a42a4093 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -106,7 +106,7 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 可配置的模型字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有消费方,随内置目录条目走。 -**按模型声明推理档位。** `reasoningEfforts` 列出模型提供的档位:每个键都会出现在输入框的档位选择器里,其值是分派在协议中实际发送的内容——`high: high` 原样透传名称,`max: ultra` 则为使用自有词汇的网关改名。没写的档位不会被提供。`off` 比较特殊:声明而不给值,选择器里会出现 Off,选中它时什么也不发送;完全不写,模型就无法停止思考。`reasoningEfforts: false` 声明一个不具备推理能力的模型,这也是从网关服务不了的目录模型上剥除推理的办法。不写这个字段,自定义模型不推理,目录模型保留目录给出的档位。 +**按模型声明推理档位。** `reasoningEfforts` 列出模型提供的档位:每个键都会出现在输入框的档位选择器里,其值是分派在协议中实际发送的内容——`high: high` 原样透传名称,`max: ultra` 则为使用自有词汇的网关改名。没写的档位不会被提供。`off` 比较特殊:声明而不给值,选择器里会出现 Off,选中它时什么也不发送;完全不写,选择器不提供 Off,请求也不携带关闭开关——由提供方自己的默认行为决定。`reasoningEfforts: false` 声明一个不具备推理能力的模型,这也是从网关服务不了的目录模型上剥除推理的办法。不写这个字段,自定义模型不推理,目录模型保留目录给出的档位。 **选定推理方言。** 档位如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加档位,诸如此类——通常靠端点 URL 来猜,而私有网关的 URL 什么也说明不了,于是 DeepSeek 风格的网关只会收到 OpenAI 方言的请求。`compat.thinkingFormat` 用来显式指定方言,`compat.supportsReasoningEffort: false` 则让该参数不再发给拒绝它的端点;两者既可设在路由上(作为其模型的默认值),也可按模型设置,且仅适用于 `openai-completions` 路由。 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 05c7376c8e..1fe2902388 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 196c347dc557d3d3993756e165f45c9212cd2d36 -README.zh.md: cee6fce7bd13d9da5fdbe5312c7c7e7f4ddf8ab5 +README.md: c5ebca23ccb4162b65a6e18132970eaf01a50b84 +README.zh.md: f916462bca915bea37c59f7a33a08e1dcc18c4c7 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 196c347dc5..c5ebca23cc 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -83,7 +83,7 @@ A profile's `models` list *replaces* the route's installed catalog rather than e `reasoningEfforts` declares a model's selectable thinking levels: each key is a level selectors offer, its value the spelling dispatch sends on the wire, so `high: high` passes the canonical name through while `max: ultra` renames it for a gateway with its own vocabulary. Keys come from pi-ai's level set (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); a level not declared is not offered. Omitting the field keeps the installed catalog entry's capability (a hand-declared model has none and does not reason); `false` declares a non-reasoning model, which is how a profile strips reasoning from a catalog model its gateway cannot serve; an empty declaration is refused rather than guessing between those two meanings. -The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, the model cannot stop thinking and selectors offer no Off; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. +The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, selectors offer no Off and an explicit Off request is refused — a request naming no effort still goes out without the parameter, so what the provider then does is its own default; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. ### Reasoning-dispatch compat switches @@ -186,6 +186,7 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. +- **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. A `models` list is an array and replaces wholesale, which is the workaround: declare the model there instead. Atomic-leaf merge semantics at the settings seam are tracked in [#2003](https://github.com/deepseek-harness/deepseek-harness/issues/2003). - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). - **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one. - **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index cee6fce7bd..f916462bca 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -83,7 +83,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 `reasoningEfforts` 声明模型可选的思考级别:每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写,因此 `high: high` 原样透传规范名称,而 `max: ultra` 则为使用自有词汇的网关改名。键取自 pi-ai 的档位集合(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`);未声明的档位不会被提供。省略该字段会保留已安装 catalog 条目的能力(手工声明的模型没有这份能力,也不推理);`false` 声明一个不具备推理能力的模型,profile 正是以此从其网关无法服务的 catalog 模型上剥除推理;空声明会被拒绝,而不是在这两种含义之间去猜。 -该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,模型就无法停止思考,选择器也不提供 Off;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 +该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,选择器不提供 Off,显式请求 Off 会被拒绝——不点名任何档位的请求仍会在不带该参数的情况下发出,提供方随后做什么是它自己的默认行为;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 ### 推理分派的 compat 开关 @@ -186,6 +186,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 +- **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。`models` 列表是数组、整体替换,这也是规避写法:把该模型改到那里声明。settings seam 的原子叶合并语义在 [#2003](https://github.com/deepseek-harness/deepseek-harness/issues/2003) 跟进。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 - **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 3285d1595a..8f1bc1a43c 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -347,9 +347,13 @@ function resolveModelCompat( } return {} } - // The installed entry's compat matches its own api, so on an - // openai-completions model it is the completions shape. - const inherited: OpenAICompletionsCompat | undefined = base?.compat + // The installed entry's compat matches the entry's OWN api — a route-level + // `api` repoint (an anthropic catalog served through an OpenAI-compatible + // gateway) leaves `base.compat` in the other protocol's shape, so it is + // inherited only while the resolved api still is the entry's. A repointed + // model starts from pi-ai's baseURL-derived detection instead, which is + // what a protocol change means for every other compat field too. + const inherited: OpenAICompletionsCompat | undefined = base?.api === api ? base.compat : undefined return { compat: { ...inherited, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 2b824f4cae..7bce3b6376 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -161,12 +161,14 @@ const compatProfile: z = z.object({ }) /** - * Keys are the offered levels, values their wire spellings. `z.const(null)` - * keeps a valueless key (`off:`) alive through validation — only resolution - * decides which levels may leave the value empty, so the diagnostic can name - * the route and model. The assertion narrows schemastery's `Dict`, which - * types every literal key as required; dict validation is per-present-key, so - * the runtime shape is the partial record. + * Keys are the offered levels, values their wire spellings. A valueless key + * (`off:`) survives validation because schemastery passes nullable data + * through before any member schema runs — `z.const(null)` only shapes the + * error for non-null wrong values and what a configuration surface renders. + * Only resolution decides which levels may leave the value empty, so the + * diagnostic can name the route and model. The assertion narrows + * schemastery's `Dict`, which types every literal key as required; dict + * validation is per-present-key, so the runtime shape is the partial record. */ const reasoningEfforts = z.dict( z.union([z.string(), z.const(null)]), diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 6f8c2ab116..d2e101505d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -524,6 +524,39 @@ describe('provider profile lifecycle', () => { expect(server.requests[1]).not.toHaveProperty('reasoning_effort') }) + it('sends a declared off value as the effort parameter instead of omitting it', async () => { + vi.stubEnv('PI_TEST_KEY', 'test-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: 'none', high: 'high' }, + }], + }, + }, + }) + + // The adapter strips a selected Off to "no reasoning option", and pi-ai's + // dispatch reads thinkingLevelMap.off exactly then — so the declared value + // still reaches the wire, which is the README's promise for `off: none`. + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('off'), + messages: [], + }) + expect(server.requests[0]).toMatchObject({ reasoning_effort: 'none' }) + }) + it('holds back reasoning_effort when the endpoint cannot take it', async () => { vi.stubEnv('PI_TEST_KEY', 'test-key') const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 790e59c260..14cb10df76 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -520,7 +520,7 @@ describe('per-model reasoning efforts', () => { expect(getSupportedThinkingLevels(model)).toEqual(['off', 'low', 'high', 'max']) }) - it('sends a declared off value on the wire instead of omitting the parameter', () => { + it('keeps a declared off value in the map for dispatch to send', () => { const model = modelOf(declared([{ id: 'm', reasoningEfforts: { off: 'none', high: 'high' } }])) expect(model.thinkingLevelMap?.off).toBe('none') expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high']) From e5d0089d5be77ac193defdd8a43c849222f28c95 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 10:55:11 +0800 Subject: [PATCH 035/100] cleanup(llm-pi-ai): share the model-entry field schemas between models and modelOverrides The duplication gate caught the two schema literals diverging only by the id field; the shared dict is now the single home, with the id added where it lives (the entry) and omitted where the dict key carries it. --- packages/llm/llm-pi-ai/src/config.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 7bce3b6376..e52af4a3f4 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -175,8 +175,8 @@ const reasoningEfforts = z.dict( z.union(THINKING_LEVELS), ) as unknown as z -const modelProfile: z = z.object({ - id: z.string().required(), +/** The fields a `models` entry and a `modelOverrides` value share; only the id's home differs. */ +const modelFields = { name: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), @@ -185,16 +185,15 @@ const modelProfile: z = z.object({ // installed catalog's capability", while `false` disables reasoning. reasoningEfforts: z.union([z.const(false), reasoningEfforts]), compat: compatProfile, +} + +const modelProfile: z = z.object({ + id: z.string().required(), + ...modelFields, }) /** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */ -const modelOverride: z = z.object({ - name: z.string(), - contextWindow: z.number().step(1).min(1), - maxTokens: z.number().step(1).min(1), - reasoningEfforts: z.union([z.const(false), reasoningEfforts]), - compat: compatProfile, -}) +const modelOverride: z = z.object(modelFields) const profile = z.object({ apiKeyEnv: z.string().role('credential-ref'), From c480796db4d8ca94f8766f268d09ddf02fc93df3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:06:29 +0800 Subject: [PATCH 036/100] docs(llm-pi-ai): state the composition-base assumption for the dict-merge limitation Maintainer ruling on the review's merge-semantics warning: per-model reasoning fields belong to the settings document, not cordis.yml entry config (the shipped composition mounts the adapter dormant), so the recursive-merge delete gap is a documented posture rather than a tracked fix; the Known Limitations entry now states the assumption instead of pointing at the closed #2003. --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 1fe2902388..790989c5d6 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: c5ebca23ccb4162b65a6e18132970eaf01a50b84 -README.zh.md: f916462bca915bea37c59f7a33a08e1dcc18c4c7 +README.md: eb67ce889193aadbd694d7aae53e47c7d20703be +README.zh.md: b4b3e3c208702fa10e5f434a70608702d0576fbd diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index c5ebca23cc..eb67ce8891 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -186,7 +186,7 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. -- **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. A `models` list is an array and replaces wholesale, which is the workaround: declare the model there instead. Atomic-leaf merge semantics at the settings seam are tracked in [#2003](https://github.com/deepseek-harness/deepseek-harness/issues/2003). +- **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. This only triggers when a `cordis.yml` entry config declares per-model reasoning fields for the same model the user layer edits; the supported posture is to leave those to the settings document (the shipped composition mounts the adapter dormant), and a `models` list is an array replacing wholesale, which is the in-band escape. - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). - **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one. - **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index f916462bca..b4b3e3c208 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -186,7 +186,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 -- **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。`models` 列表是数组、整体替换,这也是规避写法:把该模型改到那里声明。settings seam 的原子叶合并语义在 [#2003](https://github.com/deepseek-harness/deepseek-harness/issues/2003) 跟进。 +- **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。只有 `cordis.yml` entry config 为用户层正在编辑的同一模型声明了按模型推理字段才会触发;受支持的姿态是把这些字段留给 settings 文档(shipped 组合以休眠方式挂载该适配器),且 `models` 列表是数组、整体替换,这是体制内的出口。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 - **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 From c4c2355b5047675e67b1921591f40eb066fa69a2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:30:14 +0800 Subject: [PATCH 037/100] fix(host): harden skill.invoke at the enforcement boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: recheck isUserInvocable on the loaded definition (list and get collect independently, so a provider change between them could swap in a user-disabled body — the skill-tool execute template's second check); thread the carrier signal through the lookup and refuse an abandoned caller's turn as cancelled; fold lookup/loader failures into the structured internal error the list face already uses; refuse cwd-less sessions with the skill.list stance; and reject blank trailing text at the wire schema instead of relying on client trimming. --- packages/host/apiproxy/src/api-proxy.ts | 60 +++++++--- .../host/apiproxy/src/api/skills.schema.ts | 7 +- packages/host/apiproxy/src/api/skills.ts | 10 +- packages/host/apiproxy/src/fetch/handler.ts | 2 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 104 ++++++++++++++++-- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 + 6 files changed, 155 insertions(+), 30 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3970a801a3..0abfb8c9c0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2390,32 +2390,58 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, - async invoke(request) { + async invoke(request, signal) { const { sessionId, name, text } = request.payload const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) if ('refused' in resolved) return resolved.refused const agent = resolved.agent + if (agent.session.header.cwd === undefined) { + // Same stance as skill.list: a cwd-less header is a pre-project + // legacy log, and skill discovery has no root to resolve against. + return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) + } const skillRegistry = ctx.get('skills') if (skillRegistry === undefined) { return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) } - const lookup = { cwd: agent.session.header.cwd } - // isSkillName guards the registry contract; an ill-formed name is - // indistinguishable from an absent one for the caller. - const summary = isSkillName(name) - ? (await skillRegistry.list(lookup)).find(skill => skill.name === name) - : undefined - if (summary === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + const lookup = { cwd: agent.session.header.cwd, signal } + let skill + try { + // isSkillName guards the registry contract; an ill-formed name is + // indistinguishable from an absent one for the caller. + const summary = isSkillName(name) + ? (await skillRegistry.list(lookup)).find(candidate => candidate.name === name) + : undefined + if (summary === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + // The operation boundary owns user-invocation policy: client menus + // filtering their candidates is an affordance, not enforcement. + if (!isUserInvocable(summary)) { + return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) + } + const loaded = await skillRegistry.get(name, lookup) + if (loaded === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + // Recheck on the loaded definition (the skill-tool execute template): + // list and get collect independently, so a provider change between + // the two awaits can swap the winning candidate for a user-disabled + // one — the boundary must judge what it actually injects. + if (!isUserInvocable(loaded)) { + return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) + } + skill = loaded + } catch (error: unknown) { + if (signal.aborted) { + return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) + } + return err(request, { code: 'internal', message: `skill invocation failed: ${String(error)}`, details: {} }) } - // The operation boundary owns user-invocation policy: client menus - // filtering their candidates is an affordance, not enforcement. - if (!isUserInvocable(summary)) { - return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) - } - const skill = await skillRegistry.get(name, lookup) - if (skill === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + if (signal.aborted) { + // The caller already gave up (unary deadline or navigation): a turn + // it will never observe must not start. + return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) } const body = renderSkillContent(skill) const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } } diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index c1ee1024a3..1741a93a46 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -27,11 +27,14 @@ export const skillListValueSchema = z.object({ skills: z.array(skillEntrySchema), }) satisfies z.ZodType>> -/** skill.invoke request payload. */ +/** + * skill.invoke request payload. `text` is the user's trailing message; a + * blank one stays off the wire (the boundary, not client courtesy, refuses it). + */ export const skillInvokeRequestSchema = z.object({ sessionId: sessionIdSchema, name: z.string().min(1), - text: z.string().optional(), + text: z.string().min(1).optional(), }) satisfies z.ZodType>> /** skill.invoke response value. */ diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 2ade72efb9..698a9f0190 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -29,9 +29,13 @@ export interface SkillsApi { * Injects one user-invocable skill into the addressed agent as a user-role * message (the canonical `` rendering, with `text` appended * when present) and starts a turn. The host enforces user-invocation policy - * here: a model-only or unknown name is refused regardless of what a client - * menu offered. Session-backed subagents reject with `agent-busy`. + * here — on the discovery summary and again on the loaded definition, so a + * catalog change between the two lookups cannot slip a user-disabled body + * through — a model-only or unknown name is refused regardless of what a + * client menu offered. The carrier's request signal aborts the skill + * lookup and refuses injection once the caller has given up (`cancelled`). + * Session-backed subagents reject with `agent-busy`. */ - invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>): + invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>, signal: AbortSignal): Promise> } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 914c425e91..8e098680fa 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -109,7 +109,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, - 'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r) => api.skills.invoke(r) }, + 'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r, signal) => api.skills.invoke(r, signal) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 7d7062023e..5b61011370 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -305,6 +305,8 @@ describe('skill.invoke', () => { return { agent, followup } } + const live = () => new AbortController().signal + it('injects a user-invocable skill as a user message with the invocation source', async () => { const ctx = await harness() registerInvokeSkills(ctx) @@ -312,7 +314,7 @@ describe('skill.invoke', () => { const { agent, followup } = invokableAgent(ctx) const value = expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only', text: 'and check the fixture', - }))) + }), live())) expect(value).toEqual({ accepted: true }) expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as UserMessage @@ -330,7 +332,7 @@ describe('skill.invoke', () => { registerInvokeSkills(ctx) const api = createApiProxy(ctx, DEFAULTS) const { agent, followup } = invokableAgent(ctx) - expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }))) + expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live())) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only' }) const text = (message.content[0] as { text: string }).text @@ -342,39 +344,127 @@ describe('skill.invoke', () => { registerInvokeSkills(ctx) const api = createApiProxy(ctx, DEFAULTS) const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' }))) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' }), live())) expect(error.code).toBe('skill-not-invocable') expect(followup).not.toHaveBeenCalled() }) + it('rechecks user policy on the loaded definition (list/get race)', async () => { + const ctx = await harness() + // The provider flips the skill user-invocable in list but user-disabled + // in get — the window a provider change between the two collects opens. + ctx.skills.registerProvider(() => ({ + name: 'flipping', + list: () => Promise.resolve([{ + name: 'flipper', description: 'Race probe', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'custom', provider: 'flipping', rank: 0, locator: null, + }]), + get: () => Promise.resolve({ + name: 'flipper', description: 'Race probe', + invocation: { modelInvocable: false, userInvocable: false }, + source: 'custom', provider: 'flipping', + content: 'Must never inject.', + }), + })) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'flipper' }), live())) + expect(error.code).toBe('skill-not-invocable') + expect(followup).not.toHaveBeenCalled() + }) + + it('reports skill-not-found when the summary wins but the load returns nothing', async () => { + const ctx = await harness() + ctx.skills.registerProvider(() => ({ + name: 'vanishing', + list: () => Promise.resolve([{ + name: 'ghost', description: 'Vanishes on load', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'custom', provider: 'vanishing', rank: 0, locator: null, + }]), + get: () => Promise.resolve(undefined), + })) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'ghost' }), live())) + expect(error.code).toBe('skill-not-found') + expect(followup).not.toHaveBeenCalled() + }) + it('rejects an unknown or invalid skill name', async () => { const ctx = await harness() registerInvokeSkills(ctx) const api = createApiProxy(ctx, DEFAULTS) const { agent } = invokableAgent(ctx) - const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' }))) + const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' }), live())) expect(missing.code).toBe('skill-not-found') - const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }))) + const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }), live())) expect(invalid.code).toBe('skill-not-found') }) + it('folds a loader failure into a structured internal error', async () => { + const ctx = await harness() + ctx.skills.registerProvider(() => ({ + name: 'exploding', + list: () => Promise.resolve([{ + name: 'grenade', description: 'Loader throws', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'custom', provider: 'exploding', rank: 0, locator: null, + }]), + get: () => Promise.reject(new Error('disk exploded')), + })) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'grenade' }), live())) + expect(error.code).toBe('internal') + expect(error.message).toContain('skill invocation failed') + expect(followup).not.toHaveBeenCalled() + }) + + it('refuses to start a turn the caller already abandoned', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const { agent, followup } = invokableAgent(ctx) + const abort = new AbortController() + abort.abort() + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), abort.signal)) + expect(error.code).toBe('cancelled') + expect(followup).not.toHaveBeenCalled() + }) + it('surfaces a followup refusal as agent-busy', async () => { const ctx = await harness() registerInvokeSkills(ctx) const api = createApiProxy(ctx, DEFAULTS) const { agent, followup } = invokableAgent(ctx) followup.mockImplementation(() => { throw new Error('inbox closed') }) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }))) + const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live())) expect(error.code).toBe('agent-busy') }) + it('refuses a cwd-less session with the skill.list stance', async () => { + const ctx = await harness() + registerInvokeSkills(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const session = ctx.sessions.create(undefined) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const followup = vi.fn() + ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent) + const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live())) + expect(error.code).toBe('internal') + expect(error.message).toContain('has no project cwd') + expect(followup).not.toHaveBeenCalled() + }) + it('fails loud with internal when the skill registry is not mounted', async () => { const ctx = await harness({ skills: false }) const api = createApiProxy(ctx, DEFAULTS) const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup: vi.fn() } as unknown as Agent) - const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }))) + const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live())) expect(error.code).toBe('internal') expect(error.message).toContain('skill registry is absent') }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 253ac92fdf..972ccd3621 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -416,6 +416,8 @@ describe('skills domain schemas', () => { .toBe('check it') expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: '' })).toThrow() expect(() => skillInvokeRequestSchema.parse({ name: 'user-only' })).toThrow() + // A blank trailing text is refused at the wire boundary, not by client courtesy. + expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: '' })).toThrow() expect(skillInvokeValueSchema.parse({ accepted: true })).toEqual({ accepted: true }) expect(() => skillInvokeValueSchema.parse({ accepted: false })).toThrow() }) From 31ed85900d0707b309e3859484a5b3f4964721fb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:30:16 +0800 Subject: [PATCH 038/100] fix(client): review fixes for invocation rendering and turn boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-turn predicate (opensUserTurn) unifies the three parallel consumers a new node kind silently missed — produced-files turn reset, retry liveness, own-words force-scroll — so a skill invocation behaves as the turn opener it is. The menu marker resolves through ctx.locale.bind instead of a hand-rolled snapshot lookup; the dead legacy render arm goes with the removal cut; command-over-skill name precedence is now documented at the matchEnter seam; and the emptied replacement catalog keeps the no-reload sentence, with the never-published residual recorded in the Agent Note. --- ...8-user-explicit-skill-invocation.i18n.yaml | 4 ++-- ...26-08-08-user-explicit-skill-invocation.md | 1 + ...08-08-user-explicit-skill-invocation.zh.md | 1 + .../client/connection/src/client/fixture.ts | 2 +- packages/client/runtime/src/client/index.ts | 1 + .../src/client/sessions/conversation.ts | 14 +++++++++++++ .../src/client/chat/ChatView.tsx | 10 +++++---- .../src/client/chat/MessageItem.tsx | 20 ++++++++---------- .../src/client/turn-deliverables.ts | 3 ++- .../tests/produced-files.spec.tsx | 21 +++++++++++++++++++ packages/client/ui-skill/README.i18n.yaml | 4 ++-- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 11 +++++++--- .../ui-skill/tests/browser-plugin.spec.ts | 3 ++- packages/skill/tool-skill/README.i18n.yaml | 4 ++-- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/README.zh.md | 2 +- packages/skill/tool-skill/src/index.ts | 1 + 19 files changed, 77 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index ed9de78dbb..4c36032f35 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: 9249ee5c9c712e9c6aa827e97178f352728ed927 -2026-08-08-user-explicit-skill-invocation.zh.md: f15975c3b13fbf76e036fcece30253e78e7b417d +2026-08-08-user-explicit-skill-invocation.md: abe6a05283359b81ff1c3cab754d0230e599e4a0 +2026-08-08-user-explicit-skill-invocation.zh.md: e72e49236ffd2c6f664e01abbd69665eec8328e9 diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index 9249ee5c9c..abe6a05283 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -34,3 +34,4 @@ Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reaso - Every user-invocable skill invocation now costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. - The `skill-invocation` source rides `user/message`, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers. - TUI and ACP can adopt `skill.invoke` later for the same semantics; until then the TUI's client-side expansion remains its own path. +- Accepted residual of dropping the per-injection preamble: the no-reload framing rides only the catalog, and a workspace whose skills are all user-only never publishes a first catalog — an injection can arrive with no framing at all, and the model may redundantly try the `skill` tool once (the replacement catalog's empty arm carries the sentence; the never-published case does not). Publishing a catalog for framing alone was judged worse than that one recoverable error. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index f15975c3b1..e72e49236f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -34,3 +34,4 @@ Status: implemented - 每一次用户可调用 skill 的调用现在都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。 - `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 - TUI 与 ACP 之后可以为同样的语义采用 `skill.invoke`;在那之前,TUI 的客户端展开仍是它自己的路径。 +- 放弃逐次注入前导语后被接受的残余:no-reload framing 只搭乘目录,而 skill 全部为仅用户的工作区永远不会发布首个目录——注入可能在完全没有 framing 的情况下到达,模型可能多余地调用一次 `skill` 工具(替换目录的空臂携带该句;从未发布的情形没有)。仅为 framing 而发布目录被判定比这一次可恢复的错误更糟。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 75653d43e3..23b1931cfc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2779,7 +2779,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) - case 'skill.invoke': return this.api.skills.invoke(request) + case 'skill.invoke': return this.api.skills.invoke(request, signal) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index a0aa4df482..3864338e28 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -45,6 +45,7 @@ export { createSnapshotStore, defineStore, shallowEqual } from './contract/store export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' +export { opensUserTurn } from './sessions/conversation.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index d66faf5e95..1ced1b916e 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -258,6 +258,20 @@ export interface CommandNode { outcome: { kind: 'success' | 'error'; text?: string } | null } +/** + * Whether a node opens a user turn on the transcript surface. A direct user + * message and a user-explicit skill invocation both start the turn the next + * assistant answer closes; parallel consumers (turn boundaries, retry + * liveness, own-words scrolling) share this one predicate instead of each + * re-encoding the kind list. Steering stays out: an interjection lands + * mid-turn and closes nothing. + * @param node - any conversation node. + * @returns true for the user-turn-opening kinds. + */ +export function opensUserTurn(node: Pick): boolean { + return node.kind === 'user' || node.kind === 'skill-invocation' +} + /** Finalized conversation node union (kind discriminates; seq is the React key). */ export type ConversationNode = | UserMessageNode diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b0907f5a80..a841ba6751 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -24,6 +24,7 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from 'react' +import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client' import type { CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -118,7 +119,7 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n const node = nodes[index] if (node === undefined) continue if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq - if (node.kind === 'assistant' || node.kind === 'user') return null + if (node.kind === 'assistant' || opensUserTurn(node)) return null } return null } @@ -447,10 +448,11 @@ export function ChatView({ return } firstSeqRef.current = firstSeq - // Own words must be visible: a new trailing user node force-scrolls - // (send lives in the composer, so arrival is detected here, not armed there). + // Own words must be visible: a new trailing user-turn node (a prompt or an + // explicit skill invocation) force-scrolls (send lives in the composer, so + // arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current - && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' + && lastItem !== undefined && lastItem.kind === 'node' && opensUserTurn(lastItem.node) const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 661dd0cda5..af2afd9792 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -138,29 +138,27 @@ function TurnErrorItem({ node, t }: { /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The - * logged model text remains the single truth; this is presentation only. Two - * shapes decorate: legacy `name` spans (pre-decision-21 - * history) and plain-text `/name` / `@name` word-boundary tokens (decision - * 21: the sent text IS the reference — the bubble uses the same plainest - * token scan as the composer, minus the lexicon: sent tokens were validated - * at compose time, so shape alone decorates). + * logged model text remains the single truth; this is presentation only. + * Plain-text `/name` / `@name` word-boundary tokens decorate (decision 21: + * the sent text IS the reference — the bubble uses the same plainest token + * scan as the composer, minus the lexicon: sent tokens were validated at + * compose time, so shape alone decorates). */ function projectUserText(text: string): ReactNode { - const re = /([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g + const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g const parts: ReactNode[] = [] let cursor = 0 let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { - const legacy = m[1] !== undefined - const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0) - const label = legacy ? `/${m[1]}` : m[3] ?? '' + const tokenStart = m.index + (m[1]?.length ?? 0) + const label = m[2] ?? '' if (tokenStart > cursor) parts.push() parts.push( {label} , ) - cursor = legacy ? m.index + m[0].length : tokenStart + label.length + cursor = tokenStart + label.length } if (parts.length === 0) return if (cursor < text.length) parts.push() diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index c9754d1da4..b8886be0df 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -3,6 +3,7 @@ * nodes. Client-only and model-free: the vocabulary is the mutation tools' * own follow-along `locations`, never the closing prose. */ +import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -62,7 +63,7 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb } continue } - if (node.kind === 'user') { + if (opensUserTurn(node)) { turn = undefined pending = [] seen = new Set() diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 49e41ebd86..473defc4e6 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,6 +73,27 @@ describe('producedForClosing derivation', () => { expect(producedForClosing(nodes, 999)).toEqual([]) }) + it('treats a user-explicit skill invocation as a turn boundary', () => { + // The injection opens a user turn exactly like a typed prompt: files + // written before it must not spill into the turn its answer closes. + const skillInvocation = { + kind: 'skill-invocation' as const, seq: 4, time: 4_000, + name: 'hidden-demo', + content: [{ type: 'text', text: 'x' }] as never, + source: null, + } + const nodes: ConversationNode[] = [ + user(1, 'write things'), + assistant(2, 'wrote', 1), + wrote(3, 'a', 'stale.txt'), + skillInvocation, + wrote(5, 'b', 'fresh.txt'), + assistant(6, 'followed the skill', 2), + ] + expect(producedForClosing(nodes, 6)).toEqual(['fresh.txt']) + expect(producedForClosing(nodes, 6)).not.toContain('stale.txt') + }) + it('counts a generic edit and never spills across the turn boundary', () => { const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ ...toolResult(seq, callId, 'str_replace_editor'), diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index cb9eeef56e..5b80baa912 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md -README.md: c888622bc92b038413c7d0ebf63abb61b483f6f5 -README.zh.md: 3bbbc90186726356c53f375bb664d678c4926988 +README.md: ea3dbf3592995903422ec951e20c911082370dbe +README.zh.md: 5b8886e67973af9a594ff6aa2e9295f112a9f3e3 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index c888622bc9..ea3dbf3592 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. -A menu pick or an entered `/name [args]` line claims the composer into an args-tolerant `skill.invoke` transaction (`matchEnter` strong-waits the catalog; an unknown name answers undefined and stays a plain prompt). Submit trims the args, keeps blank args off the wire, and folds an RPC refusal into the composer's error outcome; the host renders the skill body and injects it as a user message before starting the turn, so invocation is deterministic for every user-invocable skill. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. Draft chip visuals still derive from the `lexicon` scan; the legacy `name` reference codec is gone (decision 21 removal cut) and `matchSpace` stays unimplemented — menu and enter own the skill flows. +A menu pick or an entered `/name [args]` line claims the composer into an args-tolerant `skill.invoke` transaction (`matchEnter` strong-waits the catalog; an unknown name answers undefined and stays a plain prompt). A skill name shared with a host command resolves to the command: adjudication polls sources in registration order and the web bundle mounts ui-command ahead of this source — deliberate precedence, matching peer products. Submit trims the args, keeps blank args off the wire, and folds an RPC refusal into the composer's error outcome; the host renders the skill body and injects it as a user message before starting the turn, so invocation is deterministic for every user-invocable skill. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. Draft chip visuals still derive from the `lexicon` scan; the legacy `name` reference codec is gone (decision 21 removal cut) and `matchSpace` stays unimplemented — menu and enter own the skill flows. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 3bbbc90186..5b8886e679 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -4,7 +4,7 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 -菜单 pick 或回车提交的一行 `/name [args]` 会把 composer 认领进一个容忍参数的 `skill.invoke` 事务(`matchEnter` 强等目录;未知名称应答 undefined,保持为普通提示词)。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 +菜单 pick 或回车提交的一行 `/name [args]` 会把 composer 认领进一个容忍参数的 `skill.invoke` 事务(`matchEnter` 强等目录;未知名称应答 undefined,保持为普通提示词)。与宿主命令同名的 skill 名解析为命令:裁决按注册顺序轮询各 source,而 web bundle 把 ui-command 挂载在本 source 之前——这是有意的优先级,与同行产品一致。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 3e23cc997b..a73370b8ff 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -121,8 +121,9 @@ export function apply(ctx: ClientContext): void { for (const key of [...fetches.keys()]) invalidate(key) } - /** User-only marker in the active language (the menu hint is plain text, resolved at candidate time). */ - const userOnlyHint = (): string => ctx.locale.getSnapshot().active === 'zh' ? zh['menu.userOnly'] : en['menu.userOnly'] + // The bound translate resolves against the registered dictionaries with the + // locale service's own fallback ladder; candidate-time reads stay plain text. + const t = ctx.locale.bind(NS) /** * Args-tolerant claim for one skill: token `/name ` plus the skill.invoke @@ -159,7 +160,7 @@ export function apply(ctx: ClientContext): void { name: skill.name, // The user-only marker rides the description (the menu's only // secondary text); `hint` is the claim-state ghost text, not a badge. - description: skill.modelInvocable ? skill.description : `${userOnlyHint()} · ${skill.description}`, + description: skill.modelInvocable ? skill.description : `${t('menu.userOnly')} · ${skill.description}`, })) }, warm(session) { @@ -183,6 +184,10 @@ export function apply(ctx: ClientContext): void { onPick({ candidate, session }) { return invokeClaim(session, candidate.name) }, + // Adjudication polls sources in registration order and the web bundle + // mounts ui-command first, so a name shared with a host command claims as + // the command — deliberate precedence (commands are explicit host + // features; peer products resolve the collision the same way), not a race. async matchEnter(session, line, signal) { const trimmed = line.trim() if (!trimmed.startsWith('/')) return undefined diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 0e098a0b30..da99ed70d3 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -53,7 +53,8 @@ function providePresentation(ctx: Context): PresentationCapture { capture.dictionaries.push({ namespace, dictionaries }) return () => { capture.localeDisposed = true } }, - getSnapshot: () => ({ active: 'zh', locales: ['zh', 'en'], revision: 0 }), + // Minimal bound-translate fake: zh dictionary lookup, key passthrough on miss. + bind: () => (key: string) => key === 'menu.userOnly' ? '仅用户' : key, }) return capture } diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 19fa44c67c..7094272679 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md -README.md: 5c6e592c670f324eb660dbe1fec168fd77e5b368 -README.zh.md: 202a621b1d4047c7d763de3b98c1a69c8c1ee1f7 +README.md: 21c3521aeff8b55940b04e804d5b8469850ec6da +README.zh.md: 74137ce7e577a4b5c6d3592b60bac3c5901a9159 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 5c6e592c67..21c3521aef 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog. ##### Skill catalog template diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 202a621b1d..74137ce7e5 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -36,7 +36,7 @@ #### 模型看到的内容 -如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板携带同一句话。 +如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。 ##### Skill 目录模板 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index aa9b509206..1d3d26a7c9 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -233,6 +233,7 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag const availability = entries.length === 0 ? [ 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.', + 'A user may still invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool for it.', ] : [ 'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.', From 7750789c8e9e797718c447e7a9483727aae1b191 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:32:28 +0800 Subject: [PATCH 039/100] docs: regenerate the module graph for the dsh-skill llm dependency --- docs/module-graph.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 9cf6f4c895..bbd0af9d8a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -297,7 +297,6 @@ flowchart TD pkg_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants - pkg_skill --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants @@ -367,6 +366,8 @@ flowchart TD pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_api_gateway --> pkg_client_connection @@ -1156,7 +1157,6 @@ flowchart TD | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | @@ -1194,6 +1194,7 @@ flowchart TD | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | From 2a7c1175be1c63023d62904d315bbff9463e1cd0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:52:18 +0800 Subject: [PATCH 040/100] fix(docs): keep doc typecheck on Host sources --- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- scripts/doc-typecheck.ts | 28 ++++++++++++---------------- 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 6bf3151311..360e4b32e4 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 7d5c5b7e46a66b2bf56ee1a1bbd57e7758a4c520 -api-gateway.zh.md: cbf62258b7bf4a1d2f657cf1fc08a8dbc0a1a939 +api-gateway.md: e8aafc173dced3c4ead07421d92401411565ece6 +api-gateway.zh.md: 92681b72ffc573cde834cece19568ec3f1d515ce diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 7d5c5b7e46..e8aafc173d 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -57,7 +57,7 @@ Remote methods may return a value synchronously or return a Promise. For coopera The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. -```ts +```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index cbf62258b7..92681b72ff 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -57,7 +57,7 @@ Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Ho Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层 runtime,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 -```ts +```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 456dedecfe..efdb03eaad 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -136,25 +136,21 @@ function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[ } /** - * Reuse both aggregate reference sets from a temp project one directory below - * root. Each referenced package remains its own program, while documentation - * examples can import either the Host or Client API. + * Reuse the Host aggregate references from a temp project one directory below + * root. Generated Client API examples opt out because their declarations do + * not exist until Host tsdown has run. */ function workspaceReferences(): { path: string }[] { - const paths = new Set() - for (const aggregate of ['tsconfig.host.json', 'tsconfig.client.json']) { - const file = join(root, aggregate) - // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path - // candidate in the workspace wildcard. - const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) - if (result.error) { - throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) - } - // `config` is typed `any` by the TS API; narrow it to the one field read here. - const { references } = result.config as { references: { path: string }[] } - for (const { path } of references) paths.add(path) + const file = join(root, 'tsconfig.host.json') + // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path + // candidate in the workspace wildcard. + const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) + if (result.error) { + throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) } - return [...paths].map(path => ({ + // `config` is typed `any` by the TS API; narrow it to the one field read here. + const { references } = result.config as { references: { path: string }[] } + return references.map(({ path }) => ({ path: path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`, })) } From d85d0806cddd8a28110b5a401453feb3477a984b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:02:58 +0800 Subject: [PATCH 041/100] fix(build): align Client build metadata --- ...08-api-remotes-generated-contract-build.i18n.yaml | 2 +- ...026-08-08-api-remotes-generated-contract-build.md | 2 +- apps/web/tests/assembled-boot.ts | 2 +- packages/client/tsdown.client.ts | 12 +++++++----- .../host/directory-picker-native/tsdown.config.ts | 2 +- scripts/run-gates.ts | 4 ++-- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml index 8b1bbf8b4d..5c1337a60c 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md -2026-08-08-api-remotes-generated-contract-build.md: ac9bb445917e11a4b57280da513d36b0f434bbaf +2026-08-08-api-remotes-generated-contract-build.md: 83848290400441f0272b220ed0d396570e1ce2dc 2026-08-08-api-remotes-generated-contract-build.zh.md: 4f9760078c209a22b9e03837fd81769e156b5df9 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md index ac9bb44591..8384829040 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md @@ -53,7 +53,7 @@ This exception follows from the real generated-contract ordering and is not a te Host tsdown enables `typertPlugin({ mode: 'workspace', faces: ['host'] })` in the normal root config. The generator uses only `tsconfig.host.json` as its program seed and produces both `typert.host.*` and the `typert.remote-client.*` projection of Host contracts; Client tsdown neither starts TypeRT nor analyzes the Client aggregate. -The TypeRT analyzer distinguishes compiler faces from runtime faces. Direct Project References in the aggregate determine which compiler face analyzes a project; only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. Runtime models follow package subpath contributions instead, so an ordinary single-project `dshClient` package may contribute both Host and Client runtime models. +The TypeRT analyzer distinguishes compiler faces from runtime faces. Direct Project References in the aggregate determine which compiler face analyzes a project; only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. Runtime models follow package subpath contributions instead, so an ordinary single-project `dshClient` package may contribute both Host and Client runtime models. Consequently, Host analysis of `api-remotes` does not also register its Client entry, while an ordinary dual-entry package does not lose its Host model. Both the Host and Client tsdown passes receive the same complete workspace of `vendor/*`, `packages/*/*`, and `apps/cli`. The root config does not scan `lib/types/client/index.js`, maintain a package classification table, or use a tsdown filter; package-local configs return entries for the current phase according to `DSH_BUILD_FACE`. diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 729428e47b..53f976af09 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -20,7 +20,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index f2b7b7a3e6..2eebcc5308 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -93,10 +93,10 @@ export function clientBundle( const client = clientConfig(id, face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js') - const host = [lib, ...(options.host ?? [])] - if (face === 'host') return options.hostPhase === true ? host : [SKIP_WORKSPACE_BUILD] - if (face === 'client') return options.hostPhase === true ? [client] : [...host, client] - return [...host, client] + const node = [lib, ...(options.companions ?? [])] + if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD] + if (face === 'client') return options.hostPhase === true ? [client] : [...node, client] + return [...node, client] } } @@ -125,7 +125,9 @@ export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig { interface ClientBundleOptions { /** Emit the Node-side artifacts during the Host pass instead of the Client pass. */ readonly hostPhase?: boolean - readonly host?: readonly UserConfig[] + /** Additional Node-side configs emitted alongside the package library. */ + readonly companions?: readonly UserConfig[] + /** Overrides for the package's primary Node-side library config. */ readonly lib?: UserConfig } diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 4a4727a5aa..6d02727f4e 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -7,7 +7,7 @@ export default clientBundle( '@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js'], { - host: [{ + companions: [{ // The artifact is lib/worker.cjs (the ./worker export the workspace // constraint keys on), bundled from the descriptive source entry. entry: { worker: 'lib/types/win32-dialog-worker.js' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index c1c3e1699c..03064cb242 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -263,8 +263,8 @@ function ciPrimaryGates(): Gate[] { ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), - // typecheck and build now drive the same root solution graph; without the - // dependency two concurrent `tsc -b` runs race the same tsbuildinfo files. + // typecheck and build both drive the Host and Client tsc graphs; without + // the dependency concurrent runs race the same tsbuildinfo files. // The tsc step is an incremental no-op after typecheck. pnpmScript('build', 'build', { needs: ['typecheck'] }), pnpmScript('publint', 'publint', { needs: ['build'] }), From 863abcb42796cfab67e3fd722206905f9785251e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:25:41 +0800 Subject: [PATCH 042/100] build: enforce split project reference faces --- ...remotes-generated-contract-build.i18n.yaml | 4 +- ...08-api-remotes-generated-contract-build.md | 2 +- ...api-remotes-generated-contract-build.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/check-workspace-constraints.ts | 2 + scripts/project-reference-faces.spec.ts | 100 ++++++++++++++ scripts/project-reference-faces.ts | 129 ++++++++++++++++++ 9 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 scripts/project-reference-faces.spec.ts create mode 100644 scripts/project-reference-faces.ts diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml index 5c1337a60c..dec1b79d6a 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md -2026-08-08-api-remotes-generated-contract-build.md: 83848290400441f0272b220ed0d396570e1ce2dc -2026-08-08-api-remotes-generated-contract-build.zh.md: 4f9760078c209a22b9e03837fd81769e156b5df9 +2026-08-08-api-remotes-generated-contract-build.md: 947465b19a7c399038ae8a3106f7563592365a8d +2026-08-08-api-remotes-generated-contract-build.zh.md: 7b559cd966c4acc055d41379c15f46fdfc649a00 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md index 8384829040..947465b19a 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md @@ -43,7 +43,7 @@ packages/api/remotes/ └─ index.ts ~~~ -The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and `host/apiproxy` reference `api/remotes/tsconfig.host.json`, while the root Client aggregate and `client/ui-goal` reference `api/remotes/tsconfig.client.json`. `ui-goal` itself remains an ordinary single Client project. +The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and `host/apiproxy` reference `api/remotes/tsconfig.host.json`, while the root Client aggregate and `client/ui-goal` reference `api/remotes/tsconfig.client.json`. `ui-goal` itself remains an ordinary single Client project. The workspace constraints gate walks the reachable Project Reference graph and rejects any face-declared project that references a split package's solution root or opposite leaf; targets with only `tsconfig.json` remain valid from either face. The two projects use disjoint `files` and separate `.tsbuildinfo` files, so they can share `lib/types` without emitting any source file twice. If both sides later need a shared implementation, move that implementation into a neutral package instead of giving the same source to two emitting projects. diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md index 4f9760078c..7b559cd966 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md @@ -43,7 +43,7 @@ packages/api/remotes/ └─ index.ts ~~~ -包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 `host/apiproxy` 引用 `api/remotes/tsconfig.host.json`;根 Client aggregate 与 `client/ui-goal` 引用 `api/remotes/tsconfig.client.json`。`ui-goal` 本身仍是普通的单一 Client project。 +包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 `host/apiproxy` 引用 `api/remotes/tsconfig.host.json`;根 Client aggregate 与 `client/ui-goal` 引用 `api/remotes/tsconfig.client.json`。`ui-goal` 本身仍是普通的单一 Client project。workspace constraints 门禁遍历可达的 Project Reference 图;凡已声明 face 的 project 引用了拆分包的 solution 根或另一侧 leaf,门禁都会拒绝,而只有 `tsconfig.json` 的目标仍可由任一 face 引用。 两个 project 使用互不重叠的 `files` 和不同的 `.tsbuildinfo`,因此可以共享 `lib/types` 而不重复发射任何源码。若未来需要两侧共用一份实现,应把实现移入中立 package,不能把同一源码同时交给两个 emitting project。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 5a1024e657..4ef3010835 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: acf279d182ca580c6e372be6fbdca8f46afdc445 -development.zh.md: 927c72be2de78f9e7f67565b9524db85c5aa1669 +development.md: 60a7ccc87e2c33e66b3d966a2907d31bb0b1efd8 +development.zh.md: 6607705be7e9f548b4f44555ad8c6cc8c2d34964 diff --git a/docs/development.md b/docs/development.md index acf279d182..60a7ccc87e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,7 +59,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge - A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. - A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary. +`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary. The root build follows the generated dependency order: diff --git a/docs/development.zh.md b/docs/development.zh.md index 927c72be2d..6607705be7 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -59,7 +59,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 - 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 - 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。 -`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。 +`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。 根构建按生成依赖排序: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 9be97f53e6..05e3d5a9ce 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -8,6 +8,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { hasTypeRTRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts' +import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts' const root = resolve(import.meta.dirname, '..') // vendor/* is single-level; packages// nests one level deeper @@ -305,6 +306,7 @@ const errors = [ ...checkRepositoryVersion(), ...workspaceManifests().flatMap(checkWorkspace), ...checkHierarchyShape(), + ...collectProjectReferenceFaceViolations(root), ] if (errors.length > 0) { console.error(errors.join('\n')) diff --git a/scripts/project-reference-faces.spec.ts b/scripts/project-reference-faces.spec.ts new file mode 100644 index 0000000000..93b3ab8c5f --- /dev/null +++ b/scripts/project-reference-faces.spec.ts @@ -0,0 +1,100 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) +} + +function workspaceFixture(options: { + readonly host: readonly string[] + readonly client: readonly string[] +}): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-project-reference-faces-')) + roots.push(root) + const shared = join(root, 'packages/core/shared') + const split = join(root, 'packages/api/split') + mkdirSync(shared, { recursive: true }) + mkdirSync(split, { recursive: true }) + writeJson(join(root, 'tsconfig.base.json'), {}) + writeJson(join(root, 'tsconfig.base.client.json'), { extends: './tsconfig.base.json' }) + writeJson(join(shared, 'package.json'), { name: '@deepseek-ai/dsh-shared' }) + writeJson(join(shared, 'tsconfig.json'), { + extends: '../../../tsconfig.base.json', + references: [], + }) + writeJson(join(split, 'package.json'), { name: '@deepseek-ai/dsh-split' }) + writeJson(join(split, 'tsconfig.json'), { + files: [], + references: [{ path: './tsconfig.host.json' }, { path: './tsconfig.client.json' }], + }) + writeJson(join(split, 'tsconfig.host.json'), { references: [{ path: '../../core/shared' }] }) + writeJson(join(split, 'tsconfig.client.json'), { references: [{ path: '../../core/shared' }] }) + writeJson(join(root, 'tsconfig.host.json'), { + references: options.host.map(path => ({ path })), + }) + writeJson(join(root, 'tsconfig.client.json'), { + references: options.client.map(path => ({ path })), + }) + return root +} + +describe('Project Reference compiler faces', () => { + it('allows neutral projects in either graph and matching split leaves', () => { + const root = workspaceFixture({ + host: ['./packages/core/shared', './packages/api/split/tsconfig.host.json'], + client: ['./packages/core/shared', './packages/api/split/tsconfig.client.json'], + }) + + expect(collectProjectReferenceFaceViolations(root)).toEqual([]) + }) + + it('rejects the opposite leaf and the solution root of a split project', () => { + const root = workspaceFixture({ + host: [ + './packages/api/split/tsconfig.host.json', + './packages/api/split/tsconfig.client.json', + ], + client: ['./packages/api/split'], + }) + + expect(collectProjectReferenceFaceViolations(root)).toEqual([ + 'tsconfig.client.json: Project Reference "./packages/api/split" enters split project packages/api/split from a Client config; reference "packages/api/split/tsconfig.client.json" instead', + 'tsconfig.host.json: Project Reference "./packages/api/split/tsconfig.client.json" enters split project packages/api/split from a Host config; reference "packages/api/split/tsconfig.host.json" instead', + ]) + }) + + it('uses the referencing project face throughout the reachable graph', () => { + const root = workspaceFixture({ + host: ['./packages/core/host-consumer'], + client: ['./packages/core/client-consumer'], + }) + const hostConsumer = join(root, 'packages/core/host-consumer') + mkdirSync(hostConsumer, { recursive: true }) + writeJson(join(hostConsumer, 'package.json'), { name: '@deepseek-ai/dsh-host-consumer' }) + writeJson(join(hostConsumer, 'tsconfig.json'), { + extends: '../../../tsconfig.base.json', + references: [{ path: '../../api/split/tsconfig.client.json' }], + }) + const clientConsumer = join(root, 'packages/core/client-consumer') + mkdirSync(clientConsumer, { recursive: true }) + writeJson(join(clientConsumer, 'package.json'), { name: '@deepseek-ai/dsh-client-consumer' }) + writeJson(join(clientConsumer, 'tsconfig.json'), { + extends: '../../../tsconfig.base.client.json', + references: [{ path: '../../api/split/tsconfig.host.json' }], + }) + + expect(collectProjectReferenceFaceViolations(root)).toEqual([ + 'packages/core/client-consumer/tsconfig.json: Project Reference "../../api/split/tsconfig.host.json" enters split project packages/api/split from a Client config; reference "packages/api/split/tsconfig.client.json" instead', + 'packages/core/host-consumer/tsconfig.json: Project Reference "../../api/split/tsconfig.client.json" enters split project packages/api/split from a Host config; reference "packages/api/split/tsconfig.host.json" instead', + ]) + }) +}) diff --git a/scripts/project-reference-faces.ts b/scripts/project-reference-faces.ts new file mode 100644 index 0000000000..0cff0dfa9e --- /dev/null +++ b/scripts/project-reference-faces.ts @@ -0,0 +1,129 @@ +/** Validate compiler-face isolation across workspace Project Reference graphs. */ + +import { existsSync, globSync } from 'node:fs' +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import ts from 'typescript' + +type ProjectFace = 'host' | 'client' + +interface ProjectReferenceConfig { + readonly extends?: unknown + readonly references?: ReadonlyArray<{ readonly path?: unknown }> +} + +const WORKSPACE_MANIFESTS = [ + 'packages/*/*/package.json', + 'apps/*/package.json', + 'vendor/*/package.json', +] as const + +/** + * Find references that enter the wrong leaf of a split Host/Client project. + * + * A single-config project is neutral and may participate in either graph. Once + * a package declares both face configs, every reachable reference must name + * the leaf matching the aggregate from which traversal began. + * + * @param root - Repository root containing both aggregate tsconfigs. + * @returns Repo-relative diagnostics for every mismatched reference edge. + */ +export function collectProjectReferenceFaceViolations(root: string): string[] { + const splitRoots = splitProjectRoots(root) + const violations: string[] = [] + const pending = [resolve(root, 'tsconfig.host.json'), resolve(root, 'tsconfig.client.json')] + const visited = new Set() + for (let configPath = pending.pop(); configPath !== undefined; configPath = pending.pop()) { + if (visited.has(configPath) || !existsSync(configPath)) continue + visited.add(configPath) + const config = projectConfig(root, configPath) + const face = projectFace(root, configPath, config) + for (const reference of projectReferences(config)) { + const targetConfig = referenceConfigPath(configPath, reference) + const splitRoot = containingSplitRoot(splitRoots, targetConfig) + if (splitRoot !== undefined) { + if (face === undefined) { + violations.push( + `${repoPath(root, configPath)}: Project Reference ${JSON.stringify(reference)} enters split project ${repoPath(root, splitRoot)} from a config with no Host/Client face`, + ) + continue + } + const expected = resolve(splitRoot, `tsconfig.${face}.json`) + if (targetConfig !== expected) { + violations.push( + `${repoPath(root, configPath)}: Project Reference ${JSON.stringify(reference)} enters split project ${repoPath(root, splitRoot)} from a ${faceLabel(face)} config; reference ${JSON.stringify(repoPath(root, expected))} instead`, + ) + continue + } + } + pending.push(targetConfig) + } + } + + return violations.sort() +} + +function splitProjectRoots(root: string): string[] { + return globSync(WORKSPACE_MANIFESTS, { cwd: root }) + .map(manifest => resolve(root, dirname(manifest))) + .filter(dir => existsSync(resolve(dir, 'tsconfig.host.json')) + && existsSync(resolve(dir, 'tsconfig.client.json'))) + .sort((left, right) => right.length - left.length) +} + +function projectConfig(root: string, configPath: string): ProjectReferenceConfig { + const read = ts.readConfigFile(configPath, path => ts.sys.readFile(path)) + if (read.error !== undefined) { + const message = ts.flattenDiagnosticMessageText(read.error.messageText, '\n') + throw new Error(`${repoPath(root, configPath)}: ${message}`) + } + return read.config as ProjectReferenceConfig +} + +function projectReferences(config: ProjectReferenceConfig): string[] { + return (config.references ?? []) + .map(reference => reference.path) + .filter((path): path is string => typeof path === 'string') +} + +function projectFace( + root: string, + configPath: string, + config: ProjectReferenceConfig, + seen = new Set(), +): ProjectFace | undefined { + if (basename(configPath) === 'tsconfig.host.json') return 'host' + if (basename(configPath) === 'tsconfig.client.json') return 'client' + if (configPath === resolve(root, 'tsconfig.base.json')) return 'host' + if (configPath === resolve(root, 'tsconfig.base.client.json')) return 'client' + if (seen.has(configPath)) return undefined + seen.add(configPath) + const parent = localExtendsConfig(configPath, config.extends) + if (parent === undefined || !existsSync(parent)) return undefined + return projectFace(root, parent, projectConfig(root, parent), seen) +} + +function localExtendsConfig(configPath: string, value: unknown): string | undefined { + if (typeof value !== 'string' || !value.startsWith('.')) return undefined + const target = resolve(dirname(configPath), value) + return target.endsWith('.json') ? target : `${target}.json` +} + +function referenceConfigPath(sourceConfig: string, reference: string): string { + const target = resolve(dirname(sourceConfig), reference) + return target.endsWith('.json') ? target : resolve(target, 'tsconfig.json') +} + +function containingSplitRoot(splitRoots: readonly string[], targetConfig: string): string | undefined { + return splitRoots.find((root) => { + const path = relative(root, targetConfig) + return path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path) + }) +} + +function repoPath(root: string, path: string): string { + return relative(root, path).split(sep).join('/') +} + +function faceLabel(face: ProjectFace): string { + return face === 'host' ? 'Host' : 'Client' +} From 8fc9032d715c2840eae39c79c520c9e8a0d4c3ac Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:36:50 +0800 Subject: [PATCH 043/100] test: refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index b2adbc38aa..0748e762dd 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology and the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology and the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成契约构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成契约构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From c08fa27e5ca3c5bfeb7e3e931a39b8e8249b1f27 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:14:49 +0800 Subject: [PATCH 044/100] feat(tool-skill): inject user-invoked skills at the pre-step gesture boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A whitespace-bounded /name token anywhere in a claimed user message, naming a user-invocable skill in the workspace directory, now injects that skill's renderSkillContent as instructions context appended after every other injection of the step — the same agent/pre-step seam the catalog, workspace instructions, and the runtime snapshot ride. Closed-set matching mirrors the command registry (a miss stays plain prose), only user-source messages are scanned, the policy check runs on the loaded definition, and this is the sole entry point for disable-model-invocation skills. The catalog's no-reload sentence now names the gesture boundary. --- packages/host/apiproxy/src/api-proxy.ts | 75 +------------ packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 2 +- packages/skill/skill/README.zh.md | 2 +- packages/skill/skill/src/index.ts | 13 ++- packages/skill/tool-skill/README.i18n.yaml | 4 +- packages/skill/tool-skill/README.md | 16 ++- packages/skill/tool-skill/README.zh.md | 16 ++- packages/skill/tool-skill/src/index.ts | 76 +++++++++++++ .../skill/tool-skill/tests/tool-skill.spec.ts | 103 ++++++++++++++++++ 10 files changed, 225 insertions(+), 86 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 0abfb8c9c0..cfcae423bc 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -18,8 +18,7 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' -import { isSkillName, isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill' -import type { SkillInvocationSource } from '@deepseek-ai/dsh-skill' +import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, @@ -1254,9 +1253,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * turn, and letting it try spends the whole pre-step path to fail inside * the adapter with a message about registration. Refusing here names the * model the session is pointed at while the draft is still in the composer. - * This is the enforcement boundary shared by `session.prompt` and - * `skill.invoke`: a client that disables its input is an affordance, and - * both methods stay callable regardless. + * This is `session.prompt`'s enforcement boundary: a client that disables + * its input is an affordance, and the method stays callable regardless. */ async function turnAgentFor( request: RpcRequest, sessionId: SessionId, @@ -2389,73 +2387,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} }) } }, - - async invoke(request, signal) { - const { sessionId, name, text } = request.payload - const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) - if ('refused' in resolved) return resolved.refused - const agent = resolved.agent - if (agent.session.header.cwd === undefined) { - // Same stance as skill.list: a cwd-less header is a pre-project - // legacy log, and skill discovery has no root to resolve against. - return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) - } - const skillRegistry = ctx.get('skills') - if (skillRegistry === undefined) { - return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const lookup = { cwd: agent.session.header.cwd, signal } - let skill - try { - // isSkillName guards the registry contract; an ill-formed name is - // indistinguishable from an absent one for the caller. - const summary = isSkillName(name) - ? (await skillRegistry.list(lookup)).find(candidate => candidate.name === name) - : undefined - if (summary === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) - } - // The operation boundary owns user-invocation policy: client menus - // filtering their candidates is an affordance, not enforcement. - if (!isUserInvocable(summary)) { - return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) - } - const loaded = await skillRegistry.get(name, lookup) - if (loaded === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) - } - // Recheck on the loaded definition (the skill-tool execute template): - // list and get collect independently, so a provider change between - // the two awaits can swap the winning candidate for a user-disabled - // one — the boundary must judge what it actually injects. - if (!isUserInvocable(loaded)) { - return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) - } - skill = loaded - } catch (error: unknown) { - if (signal.aborted) { - return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) - } - return err(request, { code: 'internal', message: `skill invocation failed: ${String(error)}`, details: {} }) - } - if (signal.aborted) { - // The caller already gave up (unary deadline or navigation): a turn - // it will never observe must not start. - return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) - } - const body = renderSkillContent(skill) - const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } } - try { - const message: UserMessage = createUserMessage({ - content: [{ type: 'text', text: text === undefined ? body : `${body}\n\n${text}` }], - source, - }) - agent.followup(message) - } catch (error: unknown) { - return err(request, { code: 'agent-busy', message: 'skill invocation rejected', details: { reason: String(error) } }) - } - return ok(request, { accepted: true as const }) - }, }, settings: { diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index fe29171cb3..2ca9cbac01 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill/README.md -README.md: 0c1b2249d8c46ad9ce8097ceeda2bd988c92eb21 -README.zh.md: 8fed350d00433206aecdb32819adc81c82745869 +README.md: 3dc2bcfa5775736717bdebcb92329d5655198234 +README.zh.md: d11f90d5a8356f06df63aa249a1f8b5851f36f5f diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 0c1b2249d8..3dc2bcfa57 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -39,7 +39,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Shared model-facing rendering -`renderSkillContent(skill)` renders one loaded skill as the canonical `` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result, and the host's user-explicit `skill.invoke` injects it as a user message, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, args? }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body. +`renderSkillContent(skill)` renders one loaded skill as the canonical `` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result and injects it at the user-explicit gesture boundary, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, form: 'instructions' }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 8fed350d00..d11f90d5a8 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -39,7 +39,7 @@ ### 共享的面向模型渲染 -`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,宿主的用户显式 `skill.invoke` 将其作为用户消息注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind({ name, args? }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。 +`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,并在用户显式的手势边界将其注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind({ name, form: 'instructions' }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。 `isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f44386d51c..42478279b4 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -121,17 +121,18 @@ export function isUserInvocable(skill: Pick): boolea } /** - * Durable message source for a user-explicit skill invocation: the host - * injects the rendered skill as a user-role message carrying this source, so - * transcript consumers present the invocation from metadata instead of - * re-parsing the model-facing text. + * Durable source for the context message a user-explicit skill invocation + * injects: the user's own words ride a plain user message, and the rendered + * skill body follows as injected `instructions`-form context carrying this + * source, so transcript consumers present the injection from metadata + * instead of re-parsing the model-facing text. */ export interface SkillInvocationSource { readonly kind: 'skill-invocation' /** Invoked skill name, validated user-invocable at the injecting boundary. */ readonly name: string - /** Trailing free text the user submitted after the skill token, when present. */ - readonly args?: string + /** Injected skill bodies are instructions for the model to follow. */ + readonly form: 'instructions' } declare module '@deepseek-ai/dsh-llm' { diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 7094272679..b9aa148fd1 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md -README.md: 21c3521aeff8b55940b04e804d5b8469850ec6da -README.zh.md: 74137ce7e577a4b5c6d3592b60bac3c5901a9159 +README.md: b7309657d85a3d2a19de78a4ee6173d742519daa +README.zh.md: f430f4027c917c5c9b97a56d1a7d7a617670b25c diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 21c3521aef..b7309657d8 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the user-explicit gesture boundary (the pre-step listener below) injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog. ##### Skill catalog template @@ -145,6 +145,20 @@ Only a failing call adds these retained tokens. Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### User-explicit invocation injection + +#### What the model sees + +A whitespace-bounded `/name` token anywhere in a claimed user message, naming a user-invocable skill in the workspace catalog, injects that skill's full `` rendering (the exact result-template shape above) as a `user`-role instructions context appended after every other injection of that step — background first, the material to act on last. Only direct user input is scanned, the check runs on the loaded definition, and unknown or user-disabled names stay ordinary prose. This is the sole entry point for `disable-model-invocation` skills, which the catalog and the `skill` tool never expose; the catalog's closing sentence tells the model to follow the injected block instead of re-loading it. + +#### Token effect + +Each gesture adds one rendered skill body to that turn as injected context — the same size as the tool result for the same skill, paid deterministically at the user's request instead of at the model's discretion. Repeated gestures for one skill within one step inject once. + +#### KV Cache effect + +Append-only; the injection lands after the reusable request prefix inside the step's message batch and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **The catalog omits `whenToUse`, source, and provider metadata** — routing is based only on name and a capped description; `whenToUse` remains provider metadata and is not rendered by the loaded wrapper either. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 74137ce7e5..f430f4027c 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -36,7 +36,7 @@ #### 模型看到的内容 -如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。 +如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:用户显式的手势边界(下文的 pre-step 监听器)会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。 ##### Skill 目录模板 @@ -145,6 +145,20 @@ Load referenced resources only as needed. 仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。 +### 用户显式调用注入 + +#### 模型看到的内容 + +已认领用户消息中任意位置、以空白为界、指名工作区目录中某个用户可调用 skill 的 `/name` token,会把该 skill 的完整 `` 渲染(与上文结果模板完全相同的形态)作为 `user` 角色的指令上下文注入,追加在该步骤所有其他注入之后——背景在前,模型要着手处理的材料在最后。只扫描直接的用户输入,检查在已加载定义上进行,未知名称和用户不可调用的名称保持为普通行文。这是 `disable-model-invocation` skill 唯一的入口,目录和 `skill` 工具永不暴露这类 skill;目录的结尾一句会告诉模型遵循注入块,而不是重新加载它。 + +#### Token 影响 + +每次手势会把一份渲染后的 skill 正文作为注入上下文加进该轮次——尺寸与同一 skill 的工具结果相同,按用户的请求确定性地支付,而非由模型自行裁量。同一步骤内对同一 skill 的重复手势只注入一次。 + +#### KV Cache 影响 + +仅追加;注入落在该步骤的消息批次中、可重用请求前缀之后,不会使现有 KV Cache 条目失效。 + ## 已知限制与暂缓事项 - **目录省略 `whenToUse`、来源和提供方元数据**:路由只基于名称和有长度上限的描述;`whenToUse` 仍是提供方元数据,加载后的包装层也不渲染它。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 1d3d26a7c9..604cd63bcf 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -15,7 +15,9 @@ import { escapeText, isModelInvocable, isSkillName, + isUserInvocable, renderSkillContent, + type SkillInvocationSource, type SkillSummary, } from '@deepseek-ai/dsh-skill' @@ -161,6 +163,49 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry') } + // User-explicit skill invocation: a claimed user message whose first line + // starts with `/` naming a user-invocable skill is a deterministic + // load gesture. The rendered body enters this step as injected + // instructions context appended after every other injection — background + // first (workspace rules, runtime policy, the catalog), the material the + // model must act on last, closest to its answer. Registration order makes + // that placement deterministic: this listener registers before the catalog + // listener, so the waterfall hands it the catalog-bearing list to extend. + // Only `source.kind === 'user'` messages are scanned — external text + // cannot forge the gesture — and a token naming no user-invocable skill + // stays ordinary prose (the command registry is a different closed + // namespace, resolved client-side before a line ever becomes a prompt). + // This is the only entry point for `disable-model-invocation` skills; the + // catalog and the `skill` tool below never see them. + ctx.on('agent/pre-step', async ( + { agent, messages, signal }, + next, + ): Promise => { + const decision = await next() + if (decision.kind === 'reject') return decision + const names = invokedSkillNames(messages) + if (names.length === 0) return decision + signal.throwIfAborted() + const lookup = { cwd: agent.session.header.cwd, signal } + const injections: UserMessage[] = [] + for (const name of names) { + const skill = await ctx.skills.get(name, lookup) + signal.throwIfAborted() + // Unknown names and user-disabled skills stay plain prose: the + // gesture was never a claim this boundary recognizes. The check sits + // on the loaded definition — the single lookup that produces what is + // actually injected. + if (skill === undefined || !isUserInvocable(skill)) continue + const source: SkillInvocationSource = { kind: 'skill-invocation', name, form: 'instructions' } + injections.push(createUserMessage({ + content: [{ type: 'text', text: renderSkillContent(skill) }], + source, + })) + } + if (injections.length === 0) return decision + return { kind: 'enter', messages: [...decision.messages, ...injections] } + }) + // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/pre-step', async ( @@ -351,3 +396,34 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void { throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`) } } + +/** + * A whitespace-bounded `/name` token (the public skill-name grammar) anywhere + * in the text — the same word-boundary shape the transcript chip decoration + * uses, so a gesture reads as one wherever it sits in the sentence. A second + * `/` or any non-boundary character breaks the match, which keeps file paths + * (`/usr/bin`) and fractions (`5/8`) out. + */ +const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g + +/** + * `/name` gesture tokens from the claimed user messages, deduplicated in + * first-seen order. Every text block of direct user input is scanned; no + * other source can forge a gesture. + * @param messages - the step's claimed batch. + * @returns candidate skill names, unvalidated against the registry. + */ +function invokedSkillNames(messages: readonly UserMessage[]): string[] { + const names: string[] = [] + for (const message of messages) { + if ((message.source as { kind?: unknown }).kind !== 'user') continue + for (const block of message.content) { + if (block.type !== 'text') continue + for (const match of block.text.matchAll(SKILL_GESTURE)) { + const name = match[2] + if (name !== undefined && !names.includes(name)) names.push(name) + } + } + } + return names +} diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 9543c196af..fe356da5da 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -915,3 +915,106 @@ describe('dsh-tool-skill', () => { expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available') }) }) + +describe('user-explicit invocation injection', () => { + async function writePolicySkill(root: string, name: string, description: string, policy: string, body: string): Promise { + const dir = join(root, name) + await mkdir(dir, { recursive: true }) + const policyLines = policy === '' ? '' : `${policy}\n` + await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n${policyLines}---\n\n${body}\n`) + } + + function gesture(text: string): UserMessage { + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) + } + + async function invokeHarness(): Promise<{ ctx: Context; agent: Agent }> { + const home = await tempDir('invoke') + const skillsRoot = join(home, '.agents', 'skills') + await writePolicySkill(skillsRoot, 'hidden-demo', 'User-only demo', 'disable-model-invocation: true', 'Say the magic word: PINEAPPLE.') + await writePolicySkill(skillsRoot, 'shared-skill', 'Ordinary skill', '', 'Shared instructions.') + await writePolicySkill(skillsRoot, 'model-only-skill', 'Model only', 'user-invocable: false', 'Model-only instructions.') + const ctx = await setup(home) + return { ctx, agent: agentForCwd(home) } + } + + it('injects a user-invocable skill named by a leading /token, after every other injection', async () => { + const { ctx, agent } = await invokeHarness() + const first = gesture('/hidden-demo what does this do') + const second = gesture('plain follow-up prose') + const decision = await proposeStep(ctx, agent, [first, second]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const kinds = decision.messages.map(message => (message.source as { kind: string }).kind) + // Background injections (the catalog here) sit between the claimed batch + // and the invoked body: the material the model must act on comes last. + expect(kinds.slice(0, 2)).toEqual(['user', 'user']) + expect(kinds.at(-1)).toBe('skill-invocation') + expect(kinds.indexOf('skill-catalog')).toBeLessThan(kinds.indexOf('skill-invocation')) + const injection = decision.messages.at(-1)! + expect(injection.source).toMatchObject({ kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' }) + const block = injection.content[0] + if (block?.type !== 'text') throw new Error('expected text injection') + expect(block.text).toContain('') + expect(block.text).toContain('Say the magic word: PINEAPPLE.') + expect(block.text).not.toContain('what does this do') + }) + + it('injects an ordinary skill the same way (one uniform user-explicit path)', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [gesture('/shared-skill go')]) + if (decision.kind !== 'enter') throw new Error('expected enter') + expect(decision.messages.some(message => + (message.source as { kind?: string; name?: string }).kind === 'skill-invocation' + && (message.source as { name?: string }).name === 'shared-skill')).toBe(true) + }) + + it('recognizes a mid-sentence gesture but not paths, fractions, or broken boundaries', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [ + gesture('please use /hidden-demo to answer this'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + expect(decision.messages.some(message => + (message.source as { kind?: string; name?: string }).kind === 'skill-invocation' + && (message.source as { name?: string }).name === 'hidden-demo')).toBe(true) + + const negative = await proposeStep(ctx, agent, [ + gesture('look under /hidden-demo/refs for the data'), + gesture('the odds are 5/8 at best'), + gesture('see foo/hidden-demo too'), + ]) + if (negative.kind !== 'enter') throw new Error('expected enter') + expect(negative.messages.some(message => + (message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false) + }) + + it('leaves unknown names and user-disabled skills as plain prose', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [ + gesture('/absent-skill do a thing'), + gesture('/model-only-skill run'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + // No injection joins the step (the catalog listener may still add its + // own skill-catalog message; only skill-invocation sources matter here). + expect(decision.messages.some(message => + (message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false) + }) + + it('never scans non-user sources and dedupes repeated gestures', async () => { + const { ctx, agent } = await invokeHarness() + const forged = createUserMessage({ + content: [{ type: 'text', text: '/hidden-demo forged' }], + source: { kind: 'skill-catalog', form: 'catalog', entries: [] }, + }) + const decision = await proposeStep(ctx, agent, [ + forged, + gesture('/hidden-demo once'), + gesture('/hidden-demo twice'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const injections = decision.messages.filter(message => + (message.source as { kind?: string }).kind === 'skill-invocation') + expect(injections).toHaveLength(1) + }) +}) From 0d53752c49975b5210fa20279601d79ad964877c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:15:52 +0800 Subject: [PATCH 045/100] refactor(host)!: retire the skill.invoke RPC for the gesture boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invocation is an ordinary session.prompt again: the pre-step gesture boundary makes it deterministic host-side for every front end, so the dedicated RPC (handler, wire schema, error codes, client face, fixtures) and ui-skill's claim machinery are net deletions. The menu keeps decision 21 exactly — a pick lands literal /name text — plus the user-only marker from skill.list's modelInvocable flag. --- ...8-user-explicit-skill-invocation.i18n.yaml | 4 +- ...26-08-08-user-explicit-skill-invocation.md | 27 ++- ...08-08-user-explicit-skill-invocation.zh.md | 25 ++- apps/web/tests/skill-user-invoke.e2e.ts | 45 ++-- .../skill-user-invoke/ui.expected.md | 10 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../client/connection/src/client/fixture.ts | 18 -- packages/client/connection/tests/fake-api.ts | 3 - packages/client/runtime/src/client/index.ts | 3 +- .../src/client/sessions/context-provenance.ts | 3 + .../src/client/sessions/conversation.ts | 34 --- .../src/client/sessions/transcript-adapter.ts | 20 +- packages/client/runtime/tests/fake-api.ts | 3 - .../runtime/tests/transcript-adapter.spec.ts | 27 ++- .../src/client/chat/ChatView.tsx | 10 +- .../src/client/chat/MessageItem.module.css | 27 --- .../src/client/chat/MessageItem.tsx | 39 +--- .../ui-conversation/src/client/locales.ts | 2 - .../tests/chat-branch-tails.spec.tsx | 35 --- .../src/client/turn-deliverables.ts | 3 +- .../tests/produced-files.spec.tsx | 20 -- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 10 +- packages/client/ui-skill/README.zh.md | 10 +- packages/client/ui-skill/src/client/index.ts | 69 ++---- .../ui-skill/tests/browser-plugin.spec.ts | 59 +---- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 - packages/host/apiproxy/src/api/rpc.schema.ts | 2 - packages/host/apiproxy/src/api/rpc.ts | 4 - .../host/apiproxy/src/api/skills.schema.ts | 15 -- packages/host/apiproxy/src/api/skills.ts | 22 +- packages/host/apiproxy/src/fetch/client.ts | 5 +- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 201 ------------------ .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 5 - .../host/apiproxy/tests/rpc-schemas.spec.ts | 18 +- 43 files changed, 143 insertions(+), 663 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index 4c36032f35..3774ba6e69 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: abe6a05283359b81ff1c3cab754d0230e599e4a0 -2026-08-08-user-explicit-skill-invocation.zh.md: e72e49236ffd2c6f664e01abbd69665eec8328e9 +2026-08-08-user-explicit-skill-invocation.md: d925938279923282170dc99934f4fa44d8ecf2b4 +2026-08-08-user-explicit-skill-invocation.zh.md: 64e23be0b42519fb9681adefcd0f05074d3aa35e diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index abe6a05283..d925938279 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -1,4 +1,4 @@ -# Agent Note: User-explicit skill invocation over skill.invoke +# Agent Note: User-explicit skill invocation at the pre-step gesture boundary Status: implemented @@ -10,28 +10,27 @@ A `disable-model-invocation: true` skill is user-only by design: it never enters ## Decision -User-explicit invocation is a deterministic host-side injection, uniform for every user-invocable skill: +User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every front end: -- `skill.invoke { sessionId, name, text? }` (host apiproxy) enforces user-invocation policy at the operation boundary (`skill-not-found` / `skill-not-invocable`), renders the skill with the shared `renderSkillContent`, appends the optional trailing text after a blank line, and injects the whole as one user-role message carrying the new `skill-invocation` `MessageSource` kind (`{ name, args? }`) before starting a turn through the same route-served gate as `session.prompt`. -- `renderSkillContent` moved from `dsh-tool-skill` to the `dsh-skill` seam: the `skill` tool result and the injection share one verbatim `` shape, and the catalog text gained the seam rule — an inline-injected skill must be followed, not re-loaded through the tool. -- `skill.list` serves every user-invocable skill and carries `modelInvocable`, so the browser menu lists user-only skills with a marker (description prefix — the `hint` field is claim-state ghost text the menu never renders). -- ui-skill claims a menu pick or an entered `/name [args]` into the invoke transaction (`matchEnter` strong-waits the catalog; unknown names stay plain prompts). The unreached legacy `name` reference codec is removed. -- The transcript materializes the injection as a dedicated `skill-invocation` node from source metadata (never re-parsed from the body) and renders a right-aligned bubble: `/name` chip, trailing text, and the injected block collapsed behind a disclosure. +- `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `workspace-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. +- Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. +- The client stays decision 21: a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. +- The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. -Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous: user-explicit triggering is programmatic injection as a user-role message with zero model participation on every product, prompt-guided tool loading exists only on the model-autonomous track, and the disable-model-invocation equivalents gate only the model-side surfaces. Kimi's origin-metadata rendering and the Claude Code/Kimi no-reload prompt rule translate directly onto `MessageSource` and the catalog sentence. +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every front end from implementing recognition. ## Alternatives considered -- **`agent.inject()` context injection** — no peer precedent; the gesture is a user turn, not an environment notice, and context-row presentation, compaction, and attribution all mismatch. Rejected. +- **`skill.invoke` RPC (host injects, client claims)** — implemented first, in two iterations: a single mixed message (user text folded into the body), then a gesture prompt plus injection delivered through inbox primitives. Rejected after real-session testing: the mixed message polluted the injection with user prose; the two-message form depended on wake-ordering subtleties (`followup` claims the whole next-turn queue synchronously inside the first waking call, stranding any later message in the next turn — reproduced live), and the dedicated RPC duplicated a path `session.prompt` already provides while leaving TUI/ACP to reimplement recognition. The pre-step seam removes the RPC, the claim machinery, and the ordering hazard outright. +- **`agent.inject()` from the RPC handler** — the inject queue (`next-step`, wake-free) is claimed ahead of the next-turn prompt, putting the injection above the gesture in the log; and pairing it with a waking `followup` reintroduces the same ordering coupling. The pre-step listener injects inside the step assembly, where ordering is explicit. - **A host `/skill ` command** (command registry, plan-mode precedent) — two-token UX, no name completion, and user-only skills stay undiscoverable in the menu; the per-cwd skill catalog also fits the static command registry poorly. Rejected. - **Client-side expansion** (fetch body, splice into the prompt) — authorization becomes bypassable client courtesy, the log loses the invocation semantics, and Codex deleted its equivalent mechanism (custom prompts) in favor of core injection. Rejected. -- **Host prompt-pipeline scanning for `/name`** (Codex `$name` core mentions) — duplicates the adjudication layer and risks swallowing literal slashes in prose; the claim path already covers the need. Rejected. -- **Per-injection preamble line** (Kimi's `User activated the skill …`) — dropped in favor of a one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result. +- **Structured reference payload on the prompt wire** (Codex's `UserInput::Skill` analogue: the client ships `{skills: [...]}` beside the text and the boundary prefers it over scanning) — considered and deferred: the existing slash-command system is itself line-text on the wire, and closed-set directory matching already removes the guesswork; recorded as a ledger item should gesture precision ever need client intent. +- **Per-injection preamble line** (Kimi's `User activated the skill …`) — dropped in favor of the one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result. ## Consequences -- Decision 21's plain-text reference path is superseded at submission: the draft still carries plain text and lexicon-derived chip visuals, but submit claims into a deterministic injection instead of shipping the literal and hoping. The model-autonomous track (catalog + `skill` tool) is unchanged. -- Every user-invocable skill invocation now costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. +- Decision 21's plain-text reference is now the whole client story: the draft carries plain text, chip visuals derive from the lexicon, and the sent text is judged by the host boundary — a hand-typed gesture, a menu pick, and a TUI prompt are indistinguishable and equally deterministic. +- Every user-invocable skill invocation costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. Mentioning a known skill name mid-sentence loads it; that is the Codex mention semantic, accepted deliberately. - The `skill-invocation` source rides `user/message`, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers. -- TUI and ACP can adopt `skill.invoke` later for the same semantics; until then the TUI's client-side expansion remains its own path. - Accepted residual of dropping the per-injection preamble: the no-reload framing rides only the catalog, and a workspace whose skills are all user-only never publishes a first catalog — an injection can arrive with no framing at all, and the model may redundantly try the `skill` tool once (the replacement catalog's empty arm carries the sentence; the never-published case does not). Publishing a catalog for framing alone was judged worse than that one recoverable error. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index e72e49236f..64e23be0b4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 经 skill.invoke 的用户显式 skill 调用 +# Agent Note: pre-step 手势边界上的用户显式 skill 调用 Status: implemented @@ -10,28 +10,27 @@ Status: implemented ## 决策 -用户显式调用是一次确定性的宿主侧注入,对每一个用户可调用的 skill 一致: +用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种前端一致: -- `skill.invoke { sessionId, name, text? }`(宿主 apiproxy)在操作边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),用共享的 `renderSkillContent` 渲染该 skill,在一个空行之后追加可选的尾随文本,并把整体作为一条携带新增 `skill-invocation` `MessageSource` kind(`{ name, args? }`)的 user 角色消息注入,随后经由与 `session.prompt` 相同的「路由是否有适配器在服务」闸门开启一个轮次。 -- `renderSkillContent` 从 `dsh-tool-skill` 移入 `dsh-skill` seam:`skill` 工具结果与注入共享同一份逐字一致的 `` 形态,目录文本则新增了这条 seam 规则——已内联注入的 skill 必须被遵循,而不是再经工具重新加载。 -- `skill.list` 提供每一个用户可调用的 skill 并携带 `modelInvocable`,因此浏览器菜单会带标记地列出仅限用户的 skill(描述前缀——`hint` 字段是认领态的 ghost text,菜单从不渲染它)。 -- ui-skill 把菜单 pick 或回车提交的 `/name [args]` 认领进 invoke 事务(`matchEnter` 强等目录;未知名称保持为普通提示词)。已不可达的旧 `name` 引用 codec 被移除。 -- transcript(文本记录)依据来源元数据把这次注入物化为专用的 `skill-invocation` 节点(绝不从正文重新解析),并渲染为一个右对齐气泡:`/name` chip、尾随文本,以及收在 disclosure 之后的注入块。 +- `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `workspace-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall 会把携带目录的列表交给它来扩展。 +- 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 +- 客户端停留在决策 21:菜单 pick 落下字面文本 `/name `,提示词将其原样发出;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——裁决在客户端把该行认领走,它尚未成为提示词。 +- 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,与 `skill` 工具结果逐字共享,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 -同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)结论一致:在每个产品上,用户显式触发都是以 user 角色消息做程序化注入、模型零参与;提示词引导的工具加载只存在于模型自主轨道上;disable-model-invocation 的对应物只把关模型侧表层。Kimi 的来源元数据渲染与 Claude Code/Kimi 的禁止重载提示词规则,可直接平移到 `MessageSource` 与目录那句话上。 +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种前端免于自行实现识别。 ## 考虑过的替代方案 -- **`agent.inject()` 上下文注入**——没有同类产品先例;这次手势是一个用户轮次,不是环境通知,而且上下文行呈现、压缩(compaction)与归属全都不匹配。否决。 +- **`skill.invoke` RPC(宿主注入、客户端认领)**——最先实现,共两轮迭代:先是单条混合消息(用户文本折进正文),后是经 inbox 原语投递的手势提示词加注入两条消息。经真实会话测试后否决:混合消息让用户行文污染了注入;两条消息的形态依赖唤醒顺序的微妙之处(`followup` 在第一个唤醒调用内同步认领整个 next-turn 队列,把之后的消息滞留到下一轮次——已实际复现),而专设 RPC 复制了 `session.prompt` 已提供的路径,还让 TUI/ACP 不得不各自重新实现识别。pre-step seam 把 RPC、认领机制与顺序隐患一并干净移除。 +- **从 RPC 处理器调用 `agent.inject()`**——inject 队列(`next-step`,不唤醒)会在 next-turn 提示词之前被认领,使注入在日志中排到手势之上;而与会唤醒的 `followup` 搭配又会重新引入同样的顺序耦合。pre-step 监听器在步骤组装内部注入,那里的顺序是显式的。 - **宿主 `/skill ` 命令**(命令注册表,plan 模式先例)——两 token 的 UX、没有名称补全、仅限用户的 skill 在菜单里仍不可发现;按 cwd 的 skill 目录也与静态命令注册表格格不入。否决。 - **客户端展开**(拉取正文、拼进提示词)——授权沦为可被绕过的客户端善意,日志失去调用语义,而且 Codex 已删除其等价机制(custom prompts)转向核心注入。否决。 -- **宿主提示词流水线扫描 `/name`**(Codex 的 `$name` core mentions)——重复了裁决层,还有吞掉普通行文中字面斜杠的风险;认领路径已经覆盖了这一需求。否决。 +- **提示词协议上的结构化引用载荷**(Codex `UserInput::Skill` 的类似物:客户端在文本旁附带 `{skills: [...]}`,边界优先采用它而不是扫描)——考虑过并暂缓:现有斜杠命令体系在协议上本身就是行文本,封闭集合的目录匹配已经消除了猜测;已记为台账事项,以备手势精确性某天需要客户端意图。 - **每次注入一条前导语**(Kimi 的 `User activated the skill …`)——弃用,改为一次性的目录句子:同样的上下文、只支付一次,且注入块与工具结果保持逐字节一致。 ## 后果 -- 决策 21 的纯文本引用路径在提交处被取代:草稿仍承载纯文本与 lexicon 派生的 chip 视觉,但提交会认领进一次确定性注入,而不是把字面文本发出去再碰运气。模型自主轨道(目录 + `skill` 工具)不变。 -- 每一次用户可调用 skill 的调用现在都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。 +- 决策 21 的纯文本引用如今就是客户端的全部故事:草稿承载纯文本,chip 视觉由 lexicon 派生,发出的文本由宿主边界评判——手动键入的手势、菜单 pick 与 TUI 提示词无从区分,也同等确定。 +- 每一次用户可调用 skill 的调用都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。在句子中间提到一个已知 skill 名称也会加载它;这就是 Codex 的 mention 语义,属于有意接受。 - `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 -- TUI 与 ACP 之后可以为同样的语义采用 `skill.invoke`;在那之前,TUI 的客户端展开仍是它自己的路径。 - 放弃逐次注入前导语后被接受的残余:no-reload framing 只搭乘目录,而 skill 全部为仅用户的工作区永远不会发布首个目录——注入可能在完全没有 framing 的情况下到达,模型可能多余地调用一次 `skill` 工具(替换目录的空臂携带该句;从未发布的情形没有)。仅为 framing 而发布目录被判定比这一次可恢复的错误更糟。 diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index f722472ded..2d5a039623 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -1,9 +1,9 @@ // Web e2e scenario: a user invokes a disable-model-invocation skill through // the composer (issue #1470). The entered `/name args` line claims into -// skill.invoke: the real host renders the skill body, injects it as a -// user-role message carrying the skill-invocation source, and starts a turn -// answered by the replay seam. The transcript shows the dedicated invocation -// card (chip + args, body collapsed) and the model's reply. +// skill.invoke: the real host forwards the gesture as an ordinary user +// prompt, injects the rendered body as instructions context named after the +// skill, and starts a turn answered by the replay seam. The transcript shows +// the gesture bubble, the collapsed context-injection row, and the reply. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' @@ -96,7 +96,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed') }) - it('claims /name args into an injection card and a replayed answer', async () => { + it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke')) const composer = page.locator('textarea:enabled').last() await composer.waitFor({ timeout: 15_000 }) @@ -112,23 +112,26 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) await composer.press('Enter') - // The injection card presents the gesture from source metadata: chip plus - // args, with the rendered collapsed behind a disclosure. - const card = page.locator('[data-skill-invocation]') - await card.waitFor({ timeout: 15_000 }) - const chip = card.locator('[data-ref-chip="skill"]') - expect(await chip.textContent()).toBe(`/${SKILL_NAME}`) - expect(await card.textContent()).toContain(ARGS_TEXT) + // The gesture stays an ordinary user bubble (decorated /name token plus + // the trailing text), ahead of the injected context. + const bubble = page.locator('[data-ref-chip="skill"]').first() + await bubble.waitFor({ timeout: 15_000 }) + expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) - const disclosure = card.locator('details') - expect(await disclosure.getAttribute('open')).toBeNull() - await card.locator('summary').click() - const body = card.locator('pre') - await body.waitFor() - expect(await body.textContent()).toContain(``) - expect(await body.textContent()).toContain('Reply with the fixture acknowledgement line.') - expect(await body.textContent()).toContain(ARGS_TEXT) - await card.locator('summary').click() + // The rendered body arrives as a context-injection row named after the + // skill; expanding it reveals the canonical block, and + // the user's text is NOT folded into it. + const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) + await injectionRow.waitFor({ timeout: 15_000 }) + await injectionRow.click() + const injectionBody = page + .locator('[data-context-injection-body]') + .filter({ hasText: `` }) + await injectionBody.waitFor({ timeout: 10_000 }) + const injected = await injectionBody.textContent() + expect(injected).toContain('Reply with the fixture acknowledgement line.') + expect(injected).not.toContain(ARGS_TEXT) + await injectionRow.click() // The injection started a turn; the replay seam answers it. await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md index b96413f89f..c77081584a 100644 --- a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -1,18 +1,20 @@ - banner: - navigation "Session hierarchy": - - button "workspace" [disabled] + - button "/user-invoke-demo and confirm the fixtur" [disabled] - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: /user-invoke-demo and confirm the fixture wiring -- group: View injected skill content -- text: {{clock}} +- text: /user-invoke-demo and confirm the fixture wiring {{clock}} - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Context injection user-invoke-demo": + - img + - img + - text: Context injection user-invoke-demo - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy": - img diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9f1bf08f9d..470e1d4816 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1471,7 +1471,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:261`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:262`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -2063,7 +2063,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:59`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:61`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 55952b3591..6b79018c22 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -677,7 +677,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:279`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:280`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4abd00c1fd..8820a0329c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,7 +1946,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise { - const missing = requireSession(request) - if (missing !== undefined) return missing - const { sessionId, name, text: args } = request.payload - const body = `\n\nBase directory for this skill: /fixture/skills/${name}\n\n\n\nFixture ${name} instructions.\n\n` - // Mirror the host: injection is a user-role message carrying the - // skill-invocation source, immediately visible in the transcript. - // The client program cannot see the host-side MessageSourceMap merge - // (sources are opaque wire JSON to the UI), so the fixture stamps the - // durable shape through the same assertion the projections read back. - const source = { kind: 'skill-invocation', name, ...args === undefined ? {} : { args } } as unknown as MessageSource - append(sessionId, { - type: 'user/message', surfaceOp: 'append', - data: userMessage(text(args === undefined ? body : `${body}\n\n${args}`), source), - }) - return ok(request, { accepted: true as const }) - }, }, goals: { // Compatibility face only: old API Proxy payloads and acknowledgements @@ -2779,7 +2762,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) - case 'skill.invoke': return this.api.skills.invoke(request, signal) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bd8efaf6a4..cc4504e538 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -163,8 +163,6 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) - onSkillInvoke: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ accepted: true as const })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), @@ -173,7 +171,6 @@ export class FakeApiClient implements IApiClient { readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)), } readonly goals: IApiClient['goals'] = { diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 3864338e28..5a1677df96 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -45,12 +45,11 @@ export { createSnapshotStore, defineStore, shallowEqual } from './contract/store export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' -export { opensUserTurn } from './sessions/conversation.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - RunningToolCall, SkillInvocationNode, + RunningToolCall, SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5d231b6bd8..6f46a510c1 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -83,6 +83,9 @@ export function contextProvenance(source: unknown): ContextProvenanceView { return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind } case 'plugin': return { role: 'inject', label: readString(record, 'plugin') ?? kind } + // A user-explicit skill invocation names the skill it injected. + case 'skill-invocation': + return { role: 'inject', label: readString(record, 'name') ?? kind } // Documented default arm of the merge-extensible source map: an unknown // producer still identifies itself by its own durable kind. default: diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1ced1b916e..fb2c281331 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -129,25 +129,6 @@ export interface ContextMessageNode { form: KnownContextForm | null } -/** - * A user-explicit skill invocation: the host injected the rendered skill as a - * user message carrying the `skill-invocation` source, so the card presents - * `/name args` from source metadata and collapses the injected body. - */ -export interface SkillInvocationNode { - kind: 'skill-invocation' - seq: number - /** Unix epoch ms from the source session event. */ - time: number - /** Invoked skill name read off the message source. */ - name: string - /** Trailing user text read off the message source, when recorded. */ - args?: string - /** Full injected model-facing content (collapsed by default in the UI). */ - content: readonly ContentBlock[] - source: unknown -} - /** Durable notice that a closed failed step is waiting for a model-request retry. */ export type ModelRetryNode = LlmRetryEventData & { kind: 'model-retry' @@ -258,27 +239,12 @@ export interface CommandNode { outcome: { kind: 'success' | 'error'; text?: string } | null } -/** - * Whether a node opens a user turn on the transcript surface. A direct user - * message and a user-explicit skill invocation both start the turn the next - * assistant answer closes; parallel consumers (turn boundaries, retry - * liveness, own-words scrolling) share this one predicate instead of each - * re-encoding the kind list. Steering stays out: an interjection lands - * mid-turn and closes nothing. - * @param node - any conversation node. - * @returns true for the user-turn-opening kinds. - */ -export function opensUserTurn(node: Pick): boolean { - return node.kind === 'user' || node.kind === 'skill-invocation' -} - /** Finalized conversation node union (kind discriminates; seq is the React key). */ export type ConversationNode = | UserMessageNode | AssistantMessageNode | SteeringMessageNode | ContextMessageNode - | SkillInvocationNode | ModelRetryNode | TurnErrorNode | ToolResultNode diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 4a05afee06..8b77807c96 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -58,22 +58,10 @@ function materializeNode( ): ConversationNode { switch (event.type) { case 'user/message': { - // A user-explicit skill invocation carries its name (and optional args) - // on the source; the dedicated node lets the card render `/name args` - // from metadata instead of re-parsing the injected body. A record whose - // name is unreadable degrades to injected context below. - const source = event.data.source as { kind?: unknown; name?: unknown; args?: unknown } - if (source.kind === 'skill-invocation' && typeof source.name === 'string') { - return { - kind: 'skill-invocation', seq: event.seq, time: event.time, - name: source.name, - ...typeof source.args === 'string' ? { args: source.args } : {}, - content: event.data.content, source: event.data.source, - } - } - // Injected context (plugin/goal source) folds to a context node, not a - // user message; only a direct human prompt is a user node. A compaction - // checkpoint never reaches here (isCompactCheckpoint routes it away). + // Injected context (plugin/goal/skill-invocation source) folds to a + // context node, not a user message; only a direct human prompt is a + // user node. A compaction checkpoint never reaches here + // (isCompactCheckpoint routes it away). if (event.data.source.kind !== 'user') { return { kind: 'context', seq: event.seq, time: event.time, diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index def535a59a..2f4299ce6c 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -198,8 +198,6 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) - onSkillInvoke: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ accepted: true as const })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), @@ -208,7 +206,6 @@ export class FakeApiClient implements IApiClient { readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)), } readonly goals: IApiClient['goals'] = { diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index e847c2cec7..a5b423c58d 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -164,29 +164,26 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) }) - it('materializes a skill-invocation source as its dedicated node', () => { + it('materializes a skill-invocation injection as a named instructions context', () => { const adapter = new TranscriptAdapter() adapter.reset([ at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'body\n\ncheck the fixture' }], - source: { kind: 'skill-invocation', name: 'hidden-demo', args: 'check the fixture' } as never, + content: [{ type: 'text', text: '/hidden-demo check the fixture' }], + source: { kind: 'user' }, }) }), at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'body' }], - source: { kind: 'skill-invocation', name: 'bare-skill' } as never, + content: [{ type: 'text', text: 'body' }], + source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never, }) }), ]) const nodes = adapter.nodes() - expect(nodes.map(node => node.kind)).toEqual(['skill-invocation', 'skill-invocation']) - expect(nodes[0]).toMatchObject({ name: 'hidden-demo', args: 'check the fixture' }) - expect(nodes[1]).toMatchObject({ name: 'bare-skill' }) - expect((nodes[1] as { args?: string }).args).toBeUndefined() - // A malformed record (no readable name) degrades to injected context, not a crash. - adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'odd' }], - source: { kind: 'skill-invocation' } as never, - }) })) - expect(adapter.nodes().at(-1)?.kind).toBe('context') + // The gesture stays a user bubble; the injected body folds to a context + // row named after the skill, presented as instructions. + expect(nodes.map(node => node.kind)).toEqual(['user', 'context']) + expect(nodes[1]).toMatchObject({ + provenance: { role: 'inject', label: 'hidden-demo' }, + form: 'instructions', + }) }) it('skips events core does not call surface-eligible, marker or not', () => { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index a841ba6751..b0907f5a80 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -24,7 +24,6 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from 'react' -import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client' import type { CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -119,7 +118,7 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n const node = nodes[index] if (node === undefined) continue if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq - if (node.kind === 'assistant' || opensUserTurn(node)) return null + if (node.kind === 'assistant' || node.kind === 'user') return null } return null } @@ -448,11 +447,10 @@ export function ChatView({ return } firstSeqRef.current = firstSeq - // Own words must be visible: a new trailing user-turn node (a prompt or an - // explicit skill invocation) force-scrolls (send lives in the composer, so - // arrival is detected here, not armed there). + // Own words must be visible: a new trailing user node force-scrolls + // (send lives in the composer, so arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current - && lastItem !== undefined && lastItem.kind === 'node' && opensUserTurn(lastItem.node) + && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 4330cde32c..5c07ace71e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -256,30 +256,3 @@ white-space: nowrap; vertical-align: baseline; } - -/* User-explicit skill invocation: the injected body collapses behind a - disclosure inside the user bubble. */ -.skillInvocationDetails { - margin-top: 6px; -} - -.skillInvocationSummary { - cursor: pointer; - font-size: 0.8em; - color: var(--dsw-alias-label-secondary); - user-select: none; -} - -.skillInvocationBody { - margin: 6px 0 0; - padding: 8px; - max-height: 320px; - overflow: auto; - border-radius: 6px; - background: var(--dsw-alias-bg-secondary, rgba(0, 0, 0, 0.06)); - font-family: var(--dsw-font-mono, monospace); - font-size: 0.78em; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; -} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index af2afd9792..30b51b3870 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -7,8 +7,8 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode, - SteeringMessageNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, + TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' @@ -22,7 +22,6 @@ export interface MessageItemProps { | UserMessageNode | SteeringMessageNode | ContextMessageNode - | SkillInvocationNode | CompactionSummaryNode | ModelRetryNode | TurnErrorNode @@ -192,38 +191,6 @@ function UserStyleBubble({ ) } -/** - * A user-explicit skill invocation: the right-aligned bubble presents the - * `/name args` gesture from source metadata (never re-parsed from the body), - * and the injected `` collapses behind a disclosure — the - * durable content is model-facing bulk, not conversation prose. - */ -function SkillInvocationRow({ node, t }: { - node: SkillInvocationNode - t: ChatViewSlotProps['t'] -}): ReactNode { - const { text } = contentText(node.content) - return ( -
-
- {`/${node.name}`} - {node.args !== undefined && } -
- {t('message.skillInvocation.expand')} -
{text}
-
-
- -
- ) -} - /** * Render one Host-authoritative pending steering item with the same visual * language as its eventual durable transcript node. @@ -285,8 +252,6 @@ export const MessageItem = memo(function MessageItem({ t={t} /> ) - case 'skill-invocation': - return case 'compaction': return case 'model-retry': diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index a340a2f634..df107d2cd2 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -79,7 +79,6 @@ export const zh = { 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', 'message.context.recall.truncated': '已截断', 'message.steering': '插话', - 'message.skillInvocation.expand': '查看注入的 skill 内容', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', @@ -220,7 +219,6 @@ export const en = { 'message.context.recall.counts': '{retained} kept · {omitted} omitted', 'message.context.recall.truncated': 'truncated', 'message.steering': 'Interjection', - 'message.skillInvocation.expand': 'View injected skill content', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 9471461cda..28b0501141 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -865,41 +865,6 @@ describe('MessageItem arms', () => { expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') }) - it('skill-invocation renders the /name chip, args, and a collapsed injected body', () => { - const body = 'instructions\n\ncheck the fixture' - const view = render( - , - ) - const chip = view.container.querySelector('[data-ref-chip="skill"]') - expect(chip?.textContent).toBe('/hidden-demo') - const details = view.container.querySelector('details') - expect(details).toBeTruthy() - expect(details?.open).toBe(false) - expect(view.getByText('查看注入的 skill 内容')).toBeTruthy() - expect(view.container.querySelector('pre')?.textContent).toBe(body) - expect(view.container.querySelector('[data-skill-invocation]')).toBeTruthy() - }) - - it('skill-invocation without args renders only the chip line', () => { - const view = render( - x
' }] as never, - source: null, - }} - />, - ) - const bubble = view.container.querySelector('[data-skill-invocation]') - expect(bubble?.textContent).toContain('/bare-skill') - expect(bubble?.textContent).not.toContain('undefined') - }) }) describe('formatMessageClock', () => { diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index b8886be0df..c9754d1da4 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -3,7 +3,6 @@ * nodes. Client-only and model-free: the vocabulary is the mutation tools' * own follow-along `locations`, never the closing prose. */ -import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -63,7 +62,7 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb } continue } - if (opensUserTurn(node)) { + if (node.kind === 'user') { turn = undefined pending = [] seen = new Set() diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 473defc4e6..845e6099c8 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,26 +73,6 @@ describe('producedForClosing derivation', () => { expect(producedForClosing(nodes, 999)).toEqual([]) }) - it('treats a user-explicit skill invocation as a turn boundary', () => { - // The injection opens a user turn exactly like a typed prompt: files - // written before it must not spill into the turn its answer closes. - const skillInvocation = { - kind: 'skill-invocation' as const, seq: 4, time: 4_000, - name: 'hidden-demo', - content: [{ type: 'text', text: 'x' }] as never, - source: null, - } - const nodes: ConversationNode[] = [ - user(1, 'write things'), - assistant(2, 'wrote', 1), - wrote(3, 'a', 'stale.txt'), - skillInvocation, - wrote(5, 'b', 'fresh.txt'), - assistant(6, 'followed the skill', 2), - ] - expect(producedForClosing(nodes, 6)).toEqual(['fresh.txt']) - expect(producedForClosing(nodes, 6)).not.toContain('stale.txt') - }) it('counts a generic edit and never spills across the turn boundary', () => { const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 5b80baa912..a1d1a2c9d5 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md -README.md: ea3dbf3592995903422ec951e20c911082370dbe -README.zh.md: 5b8886e67973af9a594ff6aa2e9295f112a9f3e3 +README.md: bdd772662acda1f8cf1b7d8a7c5532f9b37123dd +README.zh.md: 959ff0ede6d545150fb22710c8af75859966caa9 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index ea3dbf3592..bdd772662a 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. -A menu pick or an entered `/name [args]` line claims the composer into an args-tolerant `skill.invoke` transaction (`matchEnter` strong-waits the catalog; an unknown name answers undefined and stays a plain prompt). A skill name shared with a host command resolves to the command: adjudication polls sources in registration order and the web bundle mounts ui-command ahead of this source — deliberate precedence, matching peer products. Submit trims the args, keeps blank args off the wire, and folds an RPC refusal into the composer's error outcome; the host renders the skill body and injects it as a user message before starting the turn, so invocation is deterministic for every user-invocable skill. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. Draft chip visuals still derive from the `lexicon` scan; the legacy `name` reference codec is gone (decision 21 removal cut) and `matchSpace` stays unimplemented — menu and enter own the skill flows. +A pick lands the literal `/name ` text and the prompt ships the same literal (decision 21) — this source implements no adjudication hooks and no reference codec (the legacy `name` form is gone with the removal cut). Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. @@ -20,11 +20,11 @@ The browser plugin also registers a keyed `skill` toolview in `conversation.chat #### What the model sees -A claimed invocation never ships the `/name` literal. The host (`skill.invoke`) renders the canonical `` block — the same `renderSkillContent` output the `skill` tool returns — appends the user's trailing text after a blank line, and injects the whole as one user-role message carrying the `skill-invocation` source, immediately starting a turn. Loading is deterministic: the model receives the full body without being asked to call the `skill` tool, and the catalog (rendered by `dsh-tool-skill`) tells it not to re-load an inline-injected skill. +The user's message reaches the model verbatim, `/name` literal included. The host's pre-step boundary (`dsh-tool-skill`) then appends the canonical `` block — the same `renderSkillContent` output the `skill` tool returns — as injected instructions context at the end of that step's injections, closest to the model's answer. Loading is deterministic: the model receives the full body without being asked to call the `skill` tool, and the catalog tells it not to re-load an inline-injected skill. #### Token effect -One invocation adds the rendered skill body plus the trailing text to that turn's user message — the same cost as the model loading the skill through the tool, paid unconditionally instead of at the model's discretion. Menu browsing and the candidate fetch add zero model tokens. +One invocation adds the rendered skill body to that turn as injected context — the same cost as the model loading the skill through the tool, paid unconditionally instead of at the model's discretion. Menu browsing and the candidate fetch add zero model tokens. #### KV Cache effect @@ -33,5 +33,5 @@ Append-only: the injected message lands after the reusable history prefix. This ## Known Limitations and Deferred Work - **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it. -- **Enter waits on the catalog once** — `matchEnter` strong-waits the session's first catalog fetch before answering, so an enter racing a cold cache resolves against the settled catalog rather than silently missing. A menu opened before the prewarm settles still shows no skill candidates for that keystroke. -- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item). +- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference, and the host gesture boundary judges the sent text, not the menu interaction. Chip visuals derive from the lexicon scan; no occurrence identity, position tracking, or structured reference payload on the prompt wire (both are ledger items). +- **A menu opened before the prewarm settles** shows no skill candidates for that keystroke; the next keystroke re-polls the settled cache. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 5b8886e679..959ff0ede6 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -4,7 +4,7 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 -菜单 pick 或回车提交的一行 `/name [args]` 会把 composer 认领进一个容忍参数的 `skill.invoke` 事务(`matchEnter` 强等目录;未知名称应答 undefined,保持为普通提示词)。与宿主命令同名的 skill 名解析为命令:裁决按注册顺序轮询各 source,而 web bundle 把 ui-command 挂载在本 source 之前——这是有意的优先级,与同行产品一致。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 +pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本(决策 21)——本 source 不实现任何裁决钩子,也没有引用 codec(旧的 `name` 形式已随移除裁定消失)。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 ``,因此菜单 pick、手动键入的 token 与 TUI/ACP 提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 @@ -20,11 +20,11 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` sourc #### 模型看到的内容 -被认领的调用绝不会把字面文本 `/name` 发出去。宿主(`skill.invoke`)渲染规范的 `` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——在一个空行之后追加用户的尾随文本,并把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,随即开启一个轮次。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录(由 `dsh-tool-skill` 渲染)也会告诉它不要重新加载已内联注入的 skill。 +用户消息原样到达模型,字面文本 `/name` 也包含在内。随后宿主的 pre-step 边界(`dsh-tool-skill`)把规范的 `` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——作为注入的指令上下文追加在该步骤各项注入的末尾,最贴近模型的回答。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录也会告诉它不要重新加载已内联注入的 skill。 #### Token 影响 -一次调用会把渲染后的 skill 正文连同尾随文本加进该轮次的用户消息——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。 +一次调用会把渲染后的 skill 正文作为注入上下文加进该轮次——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。 #### KV Cache 影响 @@ -33,5 +33,5 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## 已知限制与暂缓事项 - **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。 -- **回车对目录只等待一次**:`matchEnter` 在应答之前强等该会话的首次目录拉取,因此与冷缓存竞速的回车会对照已落定的目录解析,而不是静默错过。预热落定之前打开的菜单,在那次击键下仍不会显示 skill 候选。 -- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。 +- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用,宿主手势边界评判的是发出的文本,而不是菜单交互。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份、位置跟踪,也没有提示词协议上的结构化引用载荷(两者都是台账事项)。 +- **预热落定之前打开的菜单**:在那次击键下不显示 skill 候选;下一次击键会重新轮询已落定的缓存。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index a73370b8ff..4e23be06be 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -2,15 +2,16 @@ * Skill reference plugin, browser half: registers the '/' skill source — * candidates from the skill.list RPC addressed by the per-call session * projection's sessionId (sessions are always agent-backed; the host - * resolves cwd from the session header). A menu pick or an entered `/name - * [args]` line claims into a skill.invoke transaction: the host renders the - * skill body and injects it as a user message, so invocation is - * deterministic for every user-invocable skill — including - * `disable-model-invocation` skills the model-side catalog never lists - * (issue #1470). The RPC rides the plugin's root-context connection - * captured at registration — the source never reads services off a per-call - * argument. Draft chip visuals still derive from the lexicon scan; the - * legacy `` reference codec is gone (decision 21 removal cut). + * resolves cwd from the session header). A pick lands the literal `/name ` + * text and the prompt ships the same literal (decision 21); determinism + * lives host-side — the pre-step boundary (`dsh-tool-skill`) recognizes a + * leading `/name` naming a user-invocable skill and injects the rendered + * body for every front end, including `disable-model-invocation` skills the + * model-side catalog never lists (issue #1470). The RPC rides the plugin's + * root-context connection captured at registration — the source never reads + * services off a per-call argument. Draft chip visuals still derive from + * the lexicon scan; the legacy `` reference codec is gone (decision + * 21 removal cut). * * Catalog fetches are cached per session (the small twin of the ui-command * directory): the per-keystroke candidates re-poll filters a settled @@ -27,7 +28,7 @@ */ import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' -import type { PickOutcome, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import { SkillRow } from './SkillRow.tsx' @@ -125,27 +126,6 @@ export function apply(ctx: ClientContext): void { // locale service's own fallback ladder; candidate-time reads stay plain text. const t = ctx.locale.bind(NS) - /** - * Args-tolerant claim for one skill: token `/name ` plus the skill.invoke - * transaction. Blank args stay off the wire; an RPC refusal folds into the - * composer's error outcome (transport failures throw). - */ - const invokeClaim = (session: { readonly sessionId: SessionId }, name: string): PickOutcome => ({ - claim: { - token: `/${name} `, - submit: async (args) => { - const trimmed = args.trim() - const { result } = await skills.invoke({ - sessionId: session.sessionId, - name, - ...trimmed === '' ? {} : { text: trimmed }, - }) - if (!result.ok) return { kind: 'error', text: `${result.error.code}: ${result.error.message}` } - return { kind: 'success' } - }, - }, - }) - const source: SlashSource = { trigger: '/', name: 'skill', @@ -181,25 +161,14 @@ export function apply(ctx: ClientContext): void { if (listeners.size === 0) lexiconListeners.delete(key) } }, - onPick({ candidate, session }) { - return invokeClaim(session, candidate.name) - }, - // Adjudication polls sources in registration order and the web bundle - // mounts ui-command first, so a name shared with a host command claims as - // the command — deliberate precedence (commands are explicit host - // features; peer products resolve the collision the same way), not a race. - async matchEnter(session, line, signal) { - const trimmed = line.trim() - if (!trimmed.startsWith('/')) return undefined - const ws = trimmed.search(/\s/) - const name = (ws === -1 ? trimmed : trimmed.slice(0, ws)).slice(1) - if (name === '') return undefined - // Strong-wait the catalog: an unknown name stays a plain prompt (the - // default sink), never a swallowed line. - const catalog = await fetchCatalog(session.sessionId) - if (signal.aborted) return undefined - if (!catalog.some(skill => skill.name === name)) return undefined - return invokeClaim(session, name) + onPick({ candidate }) { + // Decision 21: the pick lands plain text and the prompt ships the same + // literal. Determinism no longer rides the client — the host's + // pre-step boundary (dsh-tool-skill) recognizes the leading /name and + // injects the rendered body for every front end. A name shared with a + // host command still resolves to the command: adjudication claims the + // line client-side before it ever becomes a prompt. + return { text: `/${candidate.name} ` } }, } const slash = ctx.get('slash') as SlashServiceContract diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index da99ed70d3..f73a8d8bda 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -322,10 +322,9 @@ describe('lexicon', () => { }) }) -describe('pick claims into skill.invoke', () => { - it('onPick returns an args-tolerant claim whose submit invokes the skill', async () => { - const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } })) - const { source } = await bench(listOk(CATALOG), undefined, invoke) +describe('pick lands plain text (decision 21)', () => { + it('onPick returns the literal /name text with a closing space', async () => { + const { source } = await bench(listOk(CATALOG)) const outcome = source.onPick({ candidate: { name: 'commit-helper', description: 'commit flow' }, session: proj('s1'), @@ -333,58 +332,16 @@ describe('pick claims into skill.invoke', () => { via: 'menu', span: { start: 0, end: 4, draftRev: 7 }, }) - if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') - expect(outcome.claim.token).toBe('/commit-helper ') - await expect(outcome.claim.submit('check the fixture', {} as never)).resolves.toEqual({ kind: 'success' }) - expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'commit-helper', text: 'check the fixture' }) + expect(outcome).toEqual({ text: '/commit-helper ' }) }) - it('submit omits blank args and folds an RPC refusal into an error outcome', async () => { - const invoke = vi.fn(() => Promise.resolve({ - result: { ok: false as const, error: { code: 'skill-not-invocable', message: 'nope', details: { name: 'deploy' } } }, - })) - const { source } = await bench(listOk(CATALOG), undefined, invoke) - const outcome = source.onPick({ - candidate: { name: 'deploy', description: 'deploy flow' }, - session: proj('s1'), - position: 'leading', - via: 'menu', - span: { start: 0, end: 4, draftRev: 7 }, - }) - if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') - await expect(outcome.claim.submit(' ', {} as never)) - .resolves.toEqual({ kind: 'error', text: 'skill-not-invocable: nope' }) - expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy' }) - }) - - it('drops the legacy reference codec (decision 21 removal cut)', async () => { + it('keeps the legacy reference codec removed and stays out of adjudication', async () => { const { source } = await bench(listOk(CATALOG)) + // Determinism lives host-side (the pre-step gesture boundary), so the + // source neither claims lines nor serializes reference markup. expect(source.codec).toBeUndefined() - }) -}) - -describe('adjudication', () => { - it('claims an entered /name line, args-tolerant, once the catalog knows the name', async () => { - const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } })) - const { source } = await bench(listOk(CATALOG), undefined, invoke) - const outcome = await source.matchEnter!(proj('s1'), '/deploy run the smoke suite', new AbortController().signal) - if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome') - expect(outcome.claim.token).toBe('/deploy ') - await outcome.claim.submit('run the smoke suite', {} as never) - expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy', text: 'run the smoke suite' }) - }) - - it('answers undefined for unknown names, non-slash lines, and bare "/"', async () => { - const { source } = await bench(listOk(CATALOG)) - const signal = new AbortController().signal - await expect(source.matchEnter!(proj('s1'), '/unlisted do it', signal)).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), 'plain prose', signal)).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), '/', signal)).resolves.toBeUndefined() - }) - - it('never claims on space (menu and enter own the skill flows)', async () => { - const { source } = await bench(listOk(CATALOG)) expect(typeof source.matchSpace).toBe('undefined') + expect(typeof source.matchEnter).toBe('undefined') }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 017bd32970..961b48dd0b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 8d7a24b0b8b897d94ed29d5dc9ed6e9efb250fc6 -README.zh.md: c988b7540ba719d02e50d6da9595353c93766835 +README.md: 5506cbef7b778a870e1e28c3f9fdf1713f89d65f +README.zh.md: de31f653944097e9b47a966f56c418dc9fa9b1b9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 8d7a24b0b8..5506cbef7b 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -46,7 +46,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's invocation path: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point this is. `skill.invoke` is the user-explicit loading RPC: it enforces user-invocation policy at this boundary (`skill-not-found` / `skill-not-invocable`), renders the canonical `` body via the shared `renderSkillContent`, appends the optional trailing `text`, injects the whole as a user-role message carrying the `skill-invocation` source, and starts a turn through the same route-served refusal gate as `session.prompt`. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c988b7540b..de31f65394 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -46,7 +46,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的调用路径:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——此处是这类条目唯一的入口。`skill.invoke` 是用户显式加载 RPC:它在此边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),经共享的 `renderSkillContent` 渲染规范的 `` 正文,追加可选的尾随 `text`,把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,并经由与 `session.prompt` 相同的「路由是否有适配器在服务」拒绝闸门开启一个轮次。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此每一种前端(web、TUI、ACP、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index b001d54625..9a8750c722 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -50,7 +50,6 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] - 'skill.invoke': SkillsApi['invoke'] 'goal.create': GoalsApi['create'] 'goal.edit': GoalsApi['edit'] 'goal.pause': GoalsApi['pause'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index dd3fe7cf57..2733c6e940 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -51,8 +51,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), - z.object({ code: z.literal('skill-not-found'), message: z.string(), details: z.object({ name: z.string() }) }), - z.object({ code: z.literal('skill-not-invocable'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 7bf41a32e1..54bbb5a8cc 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -51,10 +51,6 @@ export interface RpcErrorDetailsMap { 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ 'unknown-command': {} - /** A skill invocation named no skill in the session's workspace (unknown or ill-formed name). */ - 'skill-not-found': { name: string } - /** A skill invocation named a skill whose policy forbids user invocation. */ - 'skill-not-invocable': { name: string } /** * A settings write was refused (schema validation, unknown namespace, * read-only provider, or storage failure); the message is the seam's text. diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index 1741a93a46..747bf19bad 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -26,18 +26,3 @@ export const skillListRequestSchema = z.object({ export const skillListValueSchema = z.object({ skills: z.array(skillEntrySchema), }) satisfies z.ZodType>> - -/** - * skill.invoke request payload. `text` is the user's trailing message; a - * blank one stays off the wire (the boundary, not client courtesy, refuses it). - */ -export const skillInvokeRequestSchema = z.object({ - sessionId: sessionIdSchema, - name: z.string().min(1), - text: z.string().min(1).optional(), -}) satisfies z.ZodType>> - -/** skill.invoke response value. */ -export const skillInvokeValueSchema = z.object({ - accepted: z.literal(true), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 698a9f0190..3b3e711a93 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -20,22 +20,14 @@ export interface SkillEntry { readonly modelInvocable: boolean } -/** Skill-domain unary methods (the map keys skill.* of RpcMethodMap). */ +/** + * Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing + * is the domain's only RPC: invocation itself is a plain `session.prompt` + * whose leading `/name` token the host recognizes at the pre-step boundary + * (`dsh-tool-skill` injects the rendered body there), so every client shares + * one deterministic path with no dedicated invocation wire. + */ export interface SkillsApi { /** Lists the user-invocable skill catalog for the session's project. */ list(request: RpcRequest<{ sessionId: SessionId }>): Promise> - - /** - * Injects one user-invocable skill into the addressed agent as a user-role - * message (the canonical `` rendering, with `text` appended - * when present) and starts a turn. The host enforces user-invocation policy - * here — on the discovery summary and again on the loaded definition, so a - * catalog change between the two lookups cannot slip a user-disabled body - * through — a model-only or unknown name is refused regardless of what a - * client menu offered. The carrier's request signal aborts the skill - * lookup and refuses injection once the caller has given up (`cancelled`). - * Session-backed subagents reject with `agent-busy`. - */ - invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>, signal: AbortSignal): - Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 574206458b..0f54d76dbc 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -39,7 +39,7 @@ import { workspaceRenameValueSchema, } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' -import { skillInvokeValueSchema, skillListValueSchema } from '../api/skills.schema.ts' +import { skillListValueSchema } from '../api/skills.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -118,7 +118,6 @@ export interface IApiClient { } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> - invoke(payload: RequestPayload<'skill.invoke'>, signal?: AbortSignal): Promise>> } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -186,7 +185,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), - invoke: (payload, signal) => this.callUnary('skill.invoke', payload, signal), } readonly goals: IApiClient['goals'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 8e098680fa..d41b51ad6d 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -41,7 +41,7 @@ import { workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' -import { skillInvokeRequestSchema, skillListRequestSchema } from '../api/skills.schema.ts' +import { skillListRequestSchema } from '../api/skills.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -109,7 +109,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, - 'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r, signal) => api.skills.invoke(r, signal) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 5b61011370..09526c5a87 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -269,207 +269,6 @@ describe('skill.list', () => { }) }) -describe('skill.invoke', () => { - /** Provider with one user-only and one model-only skill, both loadable. */ - function registerInvokeSkills(ctx: Context): void { - const summaries = [ - { - name: 'user-only', description: 'User-only', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - resourceBase: { kind: 'directory', path: '/proj/.agents/skills/user-only' }, - }, - { - name: 'model-only', description: 'Model-only', - invocation: { modelInvocable: true, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - ] as const - ctx.skills.registerProvider(() => ({ - name: 'probe', - list: () => Promise.resolve(summaries.map(summary => ({ ...summary }))), - get: candidate => Promise.resolve({ - ...summaries.find(summary => summary.name === candidate.name)!, - content: 'Follow the probe instructions.', - }), - })) - } - - /** Agent stub whose session carries a project cwd and whose followup records the injected message. */ - function invokableAgent(ctx: Context): { agent: Agent; followup: ReturnType } { - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const followup = vi.fn() - const agent = { id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent - ctx.agents.register(agent) - return { agent, followup } - } - - const live = () => new AbortController().signal - - it('injects a user-invocable skill as a user message with the invocation source', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const value = expectOk(await api.skills.invoke(request({ - sessionId: agent.id, name: 'user-only', text: 'and check the fixture', - }), live())) - expect(value).toEqual({ accepted: true }) - expect(followup).toHaveBeenCalledTimes(1) - const message = followup.mock.calls[0]?.[0] as UserMessage - expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only', args: 'and check the fixture' }) - expect(message.content).toHaveLength(1) - const text = (message.content[0] as { text: string }).text - expect(text).toContain('') - expect(text).toContain('Base directory for this skill: /proj/.agents/skills/user-only') - expect(text).toContain('Follow the probe instructions.') - expect(text.endsWith('\n\nand check the fixture')).toBe(true) - }) - - it('omits args from the source and content when no text rides the invocation', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live())) - const message = followup.mock.calls[0]?.[0] as UserMessage - expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only' }) - const text = (message.content[0] as { text: string }).text - expect(text.endsWith('')).toBe(true) - }) - - it('rejects a skill the user may not invoke', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' }), live())) - expect(error.code).toBe('skill-not-invocable') - expect(followup).not.toHaveBeenCalled() - }) - - it('rechecks user policy on the loaded definition (list/get race)', async () => { - const ctx = await harness() - // The provider flips the skill user-invocable in list but user-disabled - // in get — the window a provider change between the two collects opens. - ctx.skills.registerProvider(() => ({ - name: 'flipping', - list: () => Promise.resolve([{ - name: 'flipper', description: 'Race probe', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'flipping', rank: 0, locator: null, - }]), - get: () => Promise.resolve({ - name: 'flipper', description: 'Race probe', - invocation: { modelInvocable: false, userInvocable: false }, - source: 'custom', provider: 'flipping', - content: 'Must never inject.', - }), - })) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'flipper' }), live())) - expect(error.code).toBe('skill-not-invocable') - expect(followup).not.toHaveBeenCalled() - }) - - it('reports skill-not-found when the summary wins but the load returns nothing', async () => { - const ctx = await harness() - ctx.skills.registerProvider(() => ({ - name: 'vanishing', - list: () => Promise.resolve([{ - name: 'ghost', description: 'Vanishes on load', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'vanishing', rank: 0, locator: null, - }]), - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'ghost' }), live())) - expect(error.code).toBe('skill-not-found') - expect(followup).not.toHaveBeenCalled() - }) - - it('rejects an unknown or invalid skill name', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent } = invokableAgent(ctx) - const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' }), live())) - expect(missing.code).toBe('skill-not-found') - const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }), live())) - expect(invalid.code).toBe('skill-not-found') - }) - - it('folds a loader failure into a structured internal error', async () => { - const ctx = await harness() - ctx.skills.registerProvider(() => ({ - name: 'exploding', - list: () => Promise.resolve([{ - name: 'grenade', description: 'Loader throws', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'exploding', rank: 0, locator: null, - }]), - get: () => Promise.reject(new Error('disk exploded')), - })) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'grenade' }), live())) - expect(error.code).toBe('internal') - expect(error.message).toContain('skill invocation failed') - expect(followup).not.toHaveBeenCalled() - }) - - it('refuses to start a turn the caller already abandoned', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const abort = new AbortController() - abort.abort() - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), abort.signal)) - expect(error.code).toBe('cancelled') - expect(followup).not.toHaveBeenCalled() - }) - - it('surfaces a followup refusal as agent-busy', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - followup.mockImplementation(() => { throw new Error('inbox closed') }) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live())) - expect(error.code).toBe('agent-busy') - }) - - it('refuses a cwd-less session with the skill.list stance', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const followup = vi.fn() - ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent) - const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live())) - expect(error.code).toBe('internal') - expect(error.message).toContain('has no project cwd') - expect(followup).not.toHaveBeenCalled() - }) - - it('fails loud with internal when the skill registry is not mounted', async () => { - const ctx = await harness({ skills: false }) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup: vi.fn() } as unknown as Agent) - const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live())) - expect(error.code).toBe('internal') - expect(error.message).toContain('skill registry is absent') - }) -}) - describe('host/commands-changed frame', () => { it('broadcasts on registry change', async () => { const ctx = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 0a65c817c6..ebd56ee551 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -86,7 +86,7 @@ function scriptedApi(overrides: { execute: r => ok(r, { matched: false }), ...overrides.commands, }, - skills: { list: r => ok(r, { skills: [] }), invoke: r => ok(r, { accepted: true as const }), ...overrides.skills }, + skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, goals: { create: err, edit: err, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 09cabdcc7f..6481d75837 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -198,9 +198,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async list(request) { return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } }, - async invoke(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } - }, }, goals: { async create(request) { @@ -385,8 +382,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) - const invoked = await c.skills.invoke({ sessionId: 's' as never, name: 'commit-helper', text: 'go' }) - expect(invoked.result).toEqual({ ok: true, value: { accepted: true } }) }) it('lets command.execute finish after the 30-second default unary deadline', async () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 972ccd3621..75b6dff3f7 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -31,7 +31,7 @@ import { commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema, commandListRequestSchema, commandListValueSchema, } from '../src/api/commands.schema.ts' -import { skillEntrySchema, skillInvokeRequestSchema, skillInvokeValueSchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' +import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' @@ -74,8 +74,6 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') - expect(rpcErrorSchema.parse({ code: 'skill-not-found', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-found') - expect(rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-invocable') expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -83,7 +81,6 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) @@ -408,19 +405,6 @@ describe('skills domain schemas', () => { // modelInvocable is required wire data: an entry without it fails. expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow() }) - - it('validates the invoke request/value pair', () => { - expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only' })) - .toEqual({ sessionId: 's1', name: 'user-only' }) - expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: 'check it' }).text) - .toBe('check it') - expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: '' })).toThrow() - expect(() => skillInvokeRequestSchema.parse({ name: 'user-only' })).toThrow() - // A blank trailing text is refused at the wire boundary, not by client courtesy. - expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: '' })).toThrow() - expect(skillInvokeValueSchema.parse({ accepted: true })).toEqual({ accepted: true }) - expect(() => skillInvokeValueSchema.parse({ accepted: false })).toThrow() - }) }) describe('goals domain schemas', () => { From 2b6836a6fe2a29dcaa6ae5d78b715ba911e164f1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:16:27 +0800 Subject: [PATCH 046/100] fix(vendor): widen include's writeTask for exactOptionalPropertyTypes The debounced writer assigns undefined on flush, which a plain optional NodeJS.Timeout rejects under exactOptionalPropertyTypes; the error had been masked by stale build state until a residue cleanup invalidated it. Logged as local modification 14 in the vendor manifest. --- vendor/README.md | 1 + vendor/include/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/vendor/README.md b/vendor/README.md index 9fa97413c2..4bf1d43f46 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -43,6 +43,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +14. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 26b9305c52..5eece997c9 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -170,7 +170,7 @@ export class Include extends EntryTree { private readonly: boolean private content?: string private data?: EntryOptions[] - private writeTask?: NodeJS.Timeout + private writeTask?: NodeJS.Timeout | undefined private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { From 7f138f3f5c2fb4a91871079a10b02c71d699dba9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:52:47 +0800 Subject: [PATCH 047/100] test(examples): re-record catalog stitch sentence in agent-spine inline snapshot --- packages/examples/agent-spine-demo/tests/agent-core.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 87fd17c36b..3224487b60 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -527,6 +527,7 @@ describe('dsh-agent-spine-demo bundle', () => {
If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. + A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the \`skill\` tool again for that skill. ", "type": "user/message", }, From ed492077a5a1bcbda4d97706f9436e2356637f76 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 14:03:03 +0800 Subject: [PATCH 048/100] test(tool-skill): cover reject passthrough and non-text block scanning --- .../skill/tool-skill/tests/tool-skill.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index fe356da5da..5e0fe58855 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -1017,4 +1017,32 @@ describe('user-explicit invocation injection', () => { (message.source as { kind?: string }).kind === 'skill-invocation') expect(injections).toHaveLength(1) }) + + it('passes a downstream reject through both pre-step listeners untouched', async () => { + const { ctx, agent } = await invokeHarness() + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [gesture('/hidden-demo blocked step')], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'reject' as const }), + ) + expect(decision).toEqual({ kind: 'reject' }) + }) + + it('scans only text blocks of a user message', async () => { + const { ctx, agent } = await invokeHarness() + const mixed = createUserMessage({ + content: [ + { type: 'reasoning', text: '/hidden-demo inside a non-text block' }, + { type: 'text', text: '/shared-skill go' }, + ], + source: { kind: 'user' }, + }) + const decision = await proposeStep(ctx, agent, [mixed]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const invoked = decision.messages + .filter(message => (message.source as { kind?: string }).kind === 'skill-invocation') + .map(message => (message.source as { name: string }).name) + expect(invoked).toEqual(['shared-skill']) + }) }) From 1db327ea6ca2c976d12117cd7d18a19ac12ddc88 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 14:11:17 +0800 Subject: [PATCH 049/100] feat(web): merge compact status and summary cards --- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 14 +- ...eb-transcript-log-ordered-projection.zh.md | 14 +- apps/web/tests/seeded-history.e2e.ts | 54 ++++--- .../seeded-history/command-row.expected.md | 4 +- .../snapshots/seeded-history/ui.expected.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/commands.i18n.yaml | 4 +- docs/core-data-structures/commands.md | 9 +- docs/core-data-structures/commands.zh.md | 9 +- docs/event-producer-consumer.md | 2 +- docs/persistence-catalog.md | 14 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/sessions/conversation.ts | 13 +- .../src/client/sessions/transcript-adapter.ts | 59 +++++++- .../tests/compact-checkpoint-pin.spec.ts | 5 +- packages/client/runtime/tests/event-script.ts | 15 +- .../runtime/tests/transcript-adapter.spec.ts | 34 +++-- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 3 +- packages/client/ui-conversation/README.zh.md | 3 +- .../src/client/chat/ChatView.tsx | 33 ++++- .../src/client/chat/CompactionCommandCard.tsx | 40 ++++++ .../src/client/chat/CompactionItem.tsx | 24 +++- .../src/client/chat/chat-flow.ts | 65 ++++++++- .../src/client/contract/slots.ts | 12 +- .../ui-conversation/src/client/locales.ts | 4 + .../tests/chat-branch-tails.spec.tsx | 9 +- .../ui-conversation/tests/chat-view.spec.tsx | 132 +++++++++++++++++- .../ui-trajectory/tests/layout.spec.tsx | 5 +- .../compact/command-compact/README.i18n.yaml | 4 +- packages/compact/command-compact/README.md | 2 +- packages/compact/command-compact/README.zh.md | 2 +- packages/compact/command-compact/src/index.ts | 1 + .../tests/command-compact.spec.ts | 31 +++- .../tests/loader-composition.spec.ts | 35 ++++- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/ui/commands/README.i18n.yaml | 4 +- packages/ui/commands/README.md | 4 +- packages/ui/commands/README.zh.md | 4 +- packages/ui/commands/src/index.ts | 32 ++++- packages/ui/commands/src/invariant.ts | 10 ++ packages/ui/commands/tests/commands.spec.ts | 22 +++ packages/ui/commands/tests/invariant.spec.ts | 89 ++++++++++++ 47 files changed, 725 insertions(+), 121 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx create mode 100644 packages/ui/commands/tests/invariant.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 26108d6850..cede7ea5d9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 558d60cf6f6638c3e776396dd754d31b95819b28 -2026-07-30-web-transcript-log-ordered-projection.zh.md: 2eb216fd1970700fb54a2aa17ec5ce515869e5b0 +2026-07-30-web-transcript-log-ordered-projection.md: 3b7aaeb1178ff79e38a1b9646a9dc78efaeae48b +2026-07-30-web-transcript-log-ordered-projection.zh.md: acd198b6d3f3c5d57e233e09ee66f5d151e4f8f1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 558d60cf6f..3b7aaeb117 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -18,9 +18,11 @@ Node order is seq-monotonic by construction, and three things follow. The log-on `foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended. -The marker's summary text comes from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes the row non-expandable rather than empty, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves the text. +The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes those fields unavailable, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves them. -No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required. +The [manual compaction command](../feature/2026-07-30-queued-manual-compaction.md) returns the summary event's seq as the successful `CommandResult.sourceEventSeq`, and `command/done` persists that optional reference. Chat pairs only a successful named `/compact` command whose reference equals exactly one loaded `CompactionSummaryNode.summaryEventSeq`. The running command first renders `compact · Compacting context…`; after the checkpoint lands, the same React key renders one collapsed `compact` disclosure at the checkpoint's flow position with the count and token estimate. Input rejection, no compactable history, cancellation, and failure remain generic command rows with complete handler-authored text. Automatic compaction has no command reference and keeps the standalone context-compacted marker. + +The explicit event reference matters because manual compaction permits durable context injection while its asynchronous summary is running: command and checkpoint rows are not guaranteed to be adjacent. The command lifecycle event gains one optional field, but the compaction transaction, RPC envelope, and model-visible surface do not change; pre-release persisted logs without the field keep the former two-row soft-fall and require no migration. ## Recognizing a checkpoint: one declaration, pinned at compile time @@ -57,6 +59,10 @@ The unmerged manual-compaction-queueing branch fixes the same interleaving bug b **Keep `foldDegraded` as a defensive flag.** Rejected: it described a specific failure of a fold that no longer runs. A flag no consumer can act on, reachable only through a `console.error`, is a false contract. +**Pair the nearest `/compact` row with the next checkpoint.** Rejected: context injection may land between them, and concurrent or malformed lifecycle records must degrade without stealing another checkpoint. The command result instead names the authoritative summary event, and ambiguous references pair nothing. + +**Parse the English settlement text for item and token counts.** Rejected: handler copy is presentation text, not a stable data contract. The marker reads the structured `compact/summary` payload already owning both values. + ## Consequences Compaction no longer erases web history; a session compacted several times shows one marker per landed compaction, in log order, and the same window renders identically live and after a cold resume. The pagination hole is closed by construction rather than defended against, and `ConversationSnapshot` loses a published field, which touched thirteen files. @@ -65,8 +71,8 @@ Compaction no longer erases web history; a session compacted several times shows The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves. -The web e2e scenario now seeds a real compaction transaction over its recorded turn, so the aria golden pins both halves of the fix through the real host and a real browser: the recorded prompt and full tool output are still on screen, and one marker sits after them. The seed recording itself is untouched and stays model-authentic — replay derives the compacted turn from the recording's own surface. +The web e2e scenario now seeds a real manual command lifecycle around a compaction transaction over its recorded turn, so the aria golden pins the complete behavior through the real host and a real browser: the recorded prompt and full tool output are still on screen, exactly one `compact` row reports scale after them, and its disclosure opens the exact summary. The seed recording itself is untouched and stays model-authentic — replay derives the manual compaction from the recording's own surface. ## Deferred -The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. The marker still carries no **scale**: the checkpoint's `sourceEventSeqs` hold the shadowed count, so a separately justified count or range can be added without coupling it to progress. +The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 2eb216fd19..acd198b6d3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -18,9 +18,11 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它 `foldDegraded` 从 `ConversationSnapshot` 消失,随之消失的是哨兵填充、它们所需的 `baseSeq` 算术,以及 `degradedSeqs()`。它们的存在只为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所描述的 fold 已不再运行。删除该标志是修复的一部分,而非修复之后的清理——`degradedSeqs()` 本身已几乎就是按日志顺序的投影,只是作为抛错后的落点而非本意到达。 -标记的摘要文本来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时该行不可展开而非空白,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出文本。 +标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时这些字段不可用,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出它们。 -没有任何持久化事件、RPC 信封、压缩事务或模型可见 surface 发生变化,也不需要迁移。 +[手动压缩命令](../feature/2026-07-30-queued-manual-compaction.md)会把摘要事件的 seq 作为成功结果的 `CommandResult.sourceEventSeq` 返回,`command/done` 则持久化这项可选引用。Chat 只会配对成功且名称为 `/compact`、其引用恰好等于唯一一个已加载 `CompactionSummaryNode.summaryEventSeq` 的命令。运行中的命令先渲染为 `compact · Compacting context…`;检查点落地后,同一个 React key 会在检查点的消息流位置渲染一条收起的 `compact` 展开项,并显示条目数量和 token 估算值。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行,并保留处理器撰写的完整文本。自动压缩没有命令引用,继续使用独立的上下文已压缩标记。 + +显式事件引用之所以重要,是因为手动压缩允许在异步摘要运行期间注入持久上下文:命令行与检查点行不保证相邻。命令生命周期事件增加一个可选字段,但压缩事务、RPC 信封和模型可见 surface 均不变化;不含该字段的预发布持久日志继续采用原先的两行软退让,无须迁移。 ## 识别检查点:同一份声明,在编译期钉住 @@ -57,6 +59,10 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' **把 `foldDegraded` 留作一个防御性标志。** 已拒绝:它描述的是一个已不再运行的 fold 的特定失败。一个消费方无法据以行动、只能通过 `console.error` 到达的标志,是一份虚假契约。 +**把最近的 `/compact` 行与下一个检查点配对。** 已拒绝:两者之间可能落入上下文注入,并发或格式异常的生命周期记录也必须降级而不误取其他检查点。命令结果则指明权威摘要事件;引用存在歧义时不配对任何内容。 + +**解析英文结算文本中的条目数量和 token 数量。** 已拒绝:处理器文案是呈现文本,而非稳定的数据契约。标记读取本已持有这两个值的结构化 `compact/summary` 载荷。 + ## Consequences 压缩不再抹掉 Web 历史;一个被压缩多次的会话按日志顺序显示每次落地压缩一个标记,而同一窗口在实时与冷恢复之后渲染完全相同。分页缺口是被构造性闭合而非被防御,`ConversationSnapshot` 少了一个已发布字段,这触及十三个文件。 @@ -65,8 +71,8 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' 性能契约未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。 -Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事务,因此 aria 基准经真实宿主与真实浏览器钉住修复的两半:录制的提问与完整工具输出仍在屏幕上,其后坐着一个标记。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出被压缩的那一轮。 +Web e2e 场景现在围绕它录制的那一轮上的压缩事务播种一次真实的手动命令生命周期,因此 aria 基准经真实宿主与真实浏览器钉住完整行为:录制的提问与完整工具输出仍在屏幕上,其后恰好一条 `compact` 行报告规模,展开后会显示确切摘要。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出手动压缩。 ## Deferred -终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。标记仍不携带**规模**信息:检查点的 `sourceEventSeqs` 保存被遮蔽的数量,因此可以另行论证后添加计数或区间,而无须将其与进度耦合。 +终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 799503ef1c..fac240fbe1 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -4,8 +4,9 @@ // history RPC, history-page tool views, and the client's log-ordered transcript // events — with ZERO model calls in replay (no replay fixture; a stray stream // fails loud on the open llm seam). The cold session also carries the one -// keyless command-row surface: an Access-chip pick runs `/permission` on the -// host, so the settled row's copy has a golden here. The seed is a recorded +// keyless command-row surfaces: the seeded manual `/compact` lifecycle folds +// into its checkpoint, while an Access-chip pick later runs `/permission` on +// the host. The seed is a recorded // fixture under the // same record discipline as every other: DSH_SNAPSHOT=record drives the turn // live through the composer (real read tool against seeded workspace files) @@ -39,18 +40,18 @@ const SEED_ID = 'seeded-history-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' /** - * Append a complete, valid compaction transaction over the recorded turn's own - * surface. The recording stays model-authentic and reusable; replay adds this - * deterministic condition before seeding it cold, so the scenario pins the bug - * this change fixes — a landed compaction must not erase history the reader - * already saw — through the real host and the real browser. + * Append a complete manual `/compact` lifecycle and valid compaction transaction + * over the recorded turn's own surface. The recording stays model-authentic and + * reusable; replay adds this deterministic condition before seeding it cold, so + * the scenario pins both the log-preserving marker and its single-card command + * presentation through the real host and browser. * @param raw - the seed fixture text, already realized (placeholder-free) so * the shadow price below is computed from the exact strings the host folds. * @param meter - the composed token meter; the appended `compact/summary`'s * shadow price must be the exact heuristic price of the shadowed nodes, the * way compact-basic derives it, because the token-meter projections subtract * it verbatim. - * @returns the fixture with a compacted turn appended. + * @returns the fixture with a manual compaction lifecycle appended. */ function withCompaction(raw: string, meter: TokenMeterService): string { const lines = raw.trimEnd().split('\n') @@ -73,14 +74,10 @@ function withCompaction(raw: string, meter: TokenMeterService): string { if (first === undefined || last === undefined || tail === undefined) { throw new Error('seeded-history compaction requires a non-empty closed surface') } - // The transaction opens the turn after the recording's last closed one; read - // it from the fixture so a re-recording with a different turn count stays - // valid instead of appending a duplicate turn number. const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn if (typeof lastTurn !== 'number') { throw new Error('seeded-history compaction requires a recording ending on a closed turn') } - const turn = lastTurn + 1 let seq = tail.seq + 1 let time = tail.time + 1 /** @@ -93,8 +90,12 @@ function withCompaction(raw: string, meter: TokenMeterService): string { lines.push(JSON.stringify({ ...event, seq: taken, time: time++ })) return taken } - at({ type: 'turn/start', data: { turn } }) - const startSeq = at({ type: 'compact/start', data: { turn } }) + const commandId = 'cmd-seeded-manual-compact' + at({ + type: 'command/run', + data: { commandId, name: 'compact', args: '', source: { kind: 'user' } }, + }) + const startSeq = at({ type: 'compact/start', data: { turn: null } }) // Load-bearing exactness: the projections subtract this count verbatim, so // it must equal what the host's fold prices for these nodes. The estimator // prices message CONTENT only, so a minimal wrapper per storage shape is @@ -146,8 +147,21 @@ function withCompaction(raw: string, meter: TokenMeterService): string { surfaceOp: { op: 'replace', start: first, end: last }, sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs], }) - at({ type: 'compact/end', data: { turn } }) - at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + at({ type: 'compact/end', data: { turn: null } }) + at({ + type: 'command/done', + data: { + commandId, + kind: 'success', + text: `Compacted ${surfaceSeqs.length} history items (~${shadowedTokenCount} tokens).`, + sourceEventSeq: summarySeq, + }, + }) + // The persistence seed helper requires a terminal turn/end. Keep the manual + // command standalone, then add a closed zero-step fixture boundary after it. + const closureTurn = lastTurn + 1 + at({ type: 'turn/start', data: { turn: closureTurn } }) + at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } }) return `${lines.join('\n')}\n` } @@ -239,7 +253,11 @@ describe('web e2e: seeded history renders through cold resume', () => { await sessionRow.click() // Settled barrier for history: the recorded final assistant text renders. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), { + timeout: 10_000, + }).toBe(1) + expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0) // Tool cards render from logged tool/call + tool/result alone (views are // host-recomputed per page; the generic card is the documented default). const toolRows = page.locator('[data-variant], [data-sample]') @@ -363,7 +381,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction')) - const marker = page.getByRole('button', { name: /Context compacted/ }) + const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ }) await marker.waitFor({ timeout: 10_000 }) expect(await marker.getAttribute('aria-expanded')).toBe('false') await marker.click() diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index fe58587913..21b9cefeec 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -31,9 +31,9 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "Context compacted View compaction summary": +- button "compact Compacted 5 history items (~247 tokens)": - img - - text: Context compacted View compaction summary + - text: compact Compacted 5 history items (~247 tokens) - button "Context injection AGENTS.md": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index ce2921eac2..2502d90f90 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -31,9 +31,9 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "Context compacted View compaction summary": +- button "compact Compacted 5 history items (~247 tokens)": - img - - text: Context compacted View compaction summary + - text: compact Compacted 5 history items (~247 tokens) - button "Context injection AGENTS.md": - img - img diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ad9797262..7fd91874d3 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -343,7 +343,7 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:161`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:172`](../../packages/ui/commands/src/index.ts) ## `credentials/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4a73dc06ad..6e75b2a345 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -451,7 +451,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise Number.isSafeInteger(seq) && (seq as number) >= 0) + ? shadowedSeqs.length + : null, + shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0 + ? tokenCount as number + : null, + } +} + /** * One landed checkpoint -> the human-facing compaction marker. The summary text * comes from the checkpoint's own provenance (`sourceEventSeqs` names the @@ -170,13 +193,28 @@ function materializeCompaction( ): CompactionSummaryNode { const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs let summary: string | null = null + let summaryEventSeq: number | null = null + let shadowedItemCount: number | null = null + let shadowedTokenCount: number | null = null for (const seq of sources ?? []) { const candidate = eventIndex.get(seq) if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue - summary = compactSummaryText(candidate) + const details = compactSummaryDetails(candidate) + summary = details.summary + summaryEventSeq = candidate.seq + shadowedItemCount = details.shadowedItemCount + shadowedTokenCount = details.shadowedTokenCount break } - return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary } + return { + kind: 'compaction', + seq: checkpoint.seq, + time: checkpoint.time, + summary, + summaryEventSeq, + shadowedItemCount, + shadowedTokenCount, + } } /** Log-ordered human transcript over a paged raw event window (never consults surface order). */ @@ -321,9 +359,22 @@ export class TranscriptAdapter { return true } if ((event.type as string) !== 'command/done') return false - const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string } + const data = event.data as unknown as { + commandId: CommandId + kind: 'success' | 'error' + text?: string + sourceEventSeq?: number + } const run = this.commandIdx.get(data.commandId) - const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } } + const sourceEventSeq = data.kind === 'success' + && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 + ? data.sourceEventSeq as number + : undefined + const outcome = { + kind: data.kind, + ...data.text === undefined ? {} : { text: data.text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + } if (run === undefined) { // Cross-window cut: the run page fell out of the window — build the // node from the done alone (same soft-fall as a call-less tool result). diff --git a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts index ddc6c8adc5..aed658af51 100644 --- a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts +++ b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts @@ -36,7 +36,10 @@ describe('compaction checkpoint recognition', () => { it('recognizes a checkpoint carrying the seam-canonical source', () => { const adapter = new TranscriptAdapter() adapter.reset([canonicalCheckpoint(1)]) - expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }]) + expect(adapter.nodes()).toEqual([{ + kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null, + summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, + }]) }) it("agrees with the seam's own predicate on the source it recognizes", () => { diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 51e982e8f4..bc3c10e762 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -92,8 +92,19 @@ export const ev = { at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }), - commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent => - at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }), + commandDone: ( + seq: number, + commandId: string, + kind: 'success' | 'error' = 'success', + text?: string, + sourceEventSeq?: number, + ): SessionEvent => + at(seq, { type: 'command/done', data: { + commandId, + kind, + ...text === undefined ? {} : { text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + } }), /** A compaction's log-only `compact/summary` provenance record. */ compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent => at(seq, { type: 'compact/summary', data: { diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index e4ef3b0e1a..626c7792f7 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -223,8 +223,14 @@ describe('TranscriptAdapter', () => { checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }), ]) expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first' }, - { kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' }, + { + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first', + summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, + }, + { + kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second', + summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, + }, ]) }) @@ -296,7 +302,7 @@ describe('TranscriptAdapter', () => { ...(summary === undefined ? [] : [summary]), checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), ]) - expect(adapter.nodes()).toEqual([ + expect(adapter.nodes()).toMatchObject([ { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }, ]) }) @@ -310,7 +316,10 @@ describe('TranscriptAdapter', () => { checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), ]) expect(adapter.nodes()).toEqual([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' }, + { + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要', + summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, + }, ]) }) @@ -324,7 +333,10 @@ describe('TranscriptAdapter', () => { source: { kind: 'plugin', plugin: 'compact' }, }), })]) - expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }]) + expect(adapter.nodes()).toEqual([{ + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null, + summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, + }]) }) it('skips a non-summary provenance seq before reaching the real one', () => { @@ -468,20 +480,22 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command']) }) - it('renders the /compact row alongside the marker its own command produced', () => { - // The row that reports the compaction is a command node; dropping command - // folding would delete it together with every other slash-command row. + it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => { const adapter = new TranscriptAdapter() adapter.reset([ ev.user(0, '压缩前的问题'), ev.commandRun(1, 'cmd-compact', 'compact'), compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]), checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }), - ev.commandDone(4, 'cmd-compact', 'success', '已压缩'), + ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2), ]) const nodes = adapter.nodes() expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]]) - expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } }) + expect(nodes[1]).toMatchObject({ + name: 'compact', + outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 }, + }) + expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 }) }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b6bf25d410..ca3289ba55 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 0cf50146cc44ef0d6cc060a4c97b3d1ff454f013 -README.zh.md: 8bfb96bb9326d8fcadc3c357b6abaad88c92bd17 +README.md: 5b37065097ef60c2edf14725f4e1e1c6a52c4366 +README.zh.md: 4ec26155a124497db0fc7f351d20ecb451a18763 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0cf50146cc..5b37065097 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. +Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key, showing the replaced-item and estimated-token counts and disclosing the summary on click. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. @@ -64,7 +64,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link. - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8bfb96bb93..4ec26155a1 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 +压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行,显示被替换条目数量和估算 token 数量,并可点击展开摘要。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 @@ -64,7 +64,6 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu ## 已知限制与暂缓事项 -- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b0907f5a80..b15bc20cdb 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -32,6 +32,7 @@ import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' +import { CompactionCommandCard } from './CompactionCommandCard.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' @@ -267,17 +268,21 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec /** One command lifecycle row: keyed dispatch on the command name with the * generic card as the render-site fallback (zero registration required). A * run-less cross-window node has no name and always lands on the fallback. */ -const CommandRow = memo(function CommandRow({ renderSlot, node, t }: { +const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: { renderSlot: RenderToolRow node: CommandNode + compaction?: Extract t: ChatViewSlotProps['t'] }) { - const owner = useMemo(() => ({ node }), [node]) + const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node]) + const fallback = node.name === 'compact' || compaction !== undefined + ? + : return (
{renderSlot('conversation.chat.commandview', owner, { entryKey: node.name ?? '', - fallback: , + fallback, })}
) @@ -580,6 +585,16 @@ export function ChatView({ /> ) } + if (item.kind === 'command-compaction') { + return ( + + ) + } const node: ConversationNode = item.node if (node.kind === 'assistant') { const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined @@ -642,9 +657,17 @@ export function ChatView({
{renderItem(item)}
diff --git a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx new file mode 100644 index 0000000000..d2401e49c6 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx @@ -0,0 +1,40 @@ +// CompactionCommandCard: the `/compact` command's running row and its +// successful checkpoint disclosure. Outcomes without a checkpoint keep the +// generic command card so no-history, cancellation, and failures retain their +// complete handler-authored text. + +import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' +import { CompactionItem } from './CompactionItem.tsx' +import { GenericCommandCard } from './GenericCommandCard.tsx' +import { ToolRow } from './ToolRow.tsx' + +interface CompactionCommandCardProps extends CommandRowOwnerProps { + t: ChatViewSlotProps['t'] +} + +/** Render one manual compaction lifecycle without duplicating its checkpoint marker. */ +export function CompactionCommandCard({ node, compaction, t }: CompactionCommandCardProps) { + if (compaction !== undefined) { + return ( + + ) + } + if (node.outcome !== null) return + return ( + } + title={node.name ?? 'compact'} + summary={t('message.compaction.running')} + body={null} + state="running" + /> + ) +} diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx index 82922dd97e..7049688cc0 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -18,6 +18,10 @@ import css from './MessageItem.module.css' interface CompactionItemProps { node: CompactionSummaryNode + /** Optional command title for a manual compaction folded into this marker. */ + title?: string + /** Command settlement text used only when the summary provenance page is absent. */ + fallbackSummary?: string | null /** The owning view's locale seat. */ t: ChatViewSlotProps['t'] } @@ -27,10 +31,22 @@ interface CompactionItemProps { * @param props - the marker node off the snapshot cache. * @returns the marker row, with the summary disclosure when one is available. */ -export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) { +export const CompactionItem = memo(function CompactionItem({ + node, + title, + fallbackSummary, + t, +}: CompactionItemProps) { const [expanded, setExpanded] = useState(false) const expandable = node.summary !== null const open = expandable && expanded + const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null + ? t('message.compaction.completed', { + items: node.shadowedItemCount, + tokens: node.shadowedTokenCount, + }) + : fallbackSummary + ?? (expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')) return (
{open && node.summary !== null &&
} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index e3b8e5ba2c..22146ddb31 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -9,13 +9,48 @@ * flow share their gates. */ import type { - AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode, + AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' /** One renderable flow item; key is the React key and the parent's identity unit. */ export type ChatFlowItem = | { kind: 'node'; key: string; node: ConversationNode } | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } + | { + kind: 'command-compaction' + key: string + command: CommandNode + compaction: CompactionSummaryNode + } + +/** Match explicit command outcome references to exactly one compaction checkpoint. */ +function commandCompactionPairs(nodes: readonly ConversationNode[]): { + readonly byCommandId: ReadonlyMap + readonly byCompactionSeq: ReadonlyMap +} { + const commandsBySource = new Map() + for (const node of nodes) { + if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue + const source = node.outcome.sourceEventSeq + if (source === undefined) continue + commandsBySource.set(source, commandsBySource.has(source) ? null : node) + } + const compactionsBySummary = new Map() + for (const node of nodes) { + if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue + const summary = node.summaryEventSeq + compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node) + } + const byCommandId = new Map() + const byCompactionSeq = new Map() + for (const [source, command] of commandsBySource) { + const compaction = compactionsBySummary.get(source) + if (command === null || compaction === undefined || compaction === null) continue + byCommandId.set(command.commandId, compaction) + byCompactionSeq.set(compaction.seq, command) + } + return { byCommandId, byCompactionSeq } +} /** * True when the node has model-visible text content worth IconActions chrome. @@ -115,9 +150,29 @@ export function assistantBranchSeqs( */ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { const items: ChatFlowItem[] = [] + const pairs = commandCompactionPairs(nodes) let group: ToolResultNode[] | null = null for (const node of nodes) { if (rendersNothing(node)) continue + if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) { + group = null + continue + } + if (node.kind === 'compaction') { + group = null + const command = pairs.byCompactionSeq.get(node.seq) + if (command !== undefined) { + items.push({ + kind: 'command-compaction', + key: `c${command.commandId}`, + command, + compaction: node, + }) + } else { + items.push({ kind: 'node', key: `n${node.seq}`, node }) + } + continue + } if (node.kind === 'tool-result') { if (group === null) { group = [node] @@ -138,7 +193,13 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem } } else { group = null - items.push({ kind: 'node', key: `n${node.seq}`, node }) + items.push({ + kind: 'node', + key: node.kind === 'command' && node.name === 'compact' + ? `c${node.commandId}` + : `n${node.seq}`, + node, + }) } } return items diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 0284784e6e..be57f08523 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react' import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerBlock } from '../input/blocks.ts' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' @@ -217,14 +217,16 @@ export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'> /** * Owner share of the per-command row slot: the frozen {@link CommandNode} * slice off the snapshot (cache-stable reference — memo premise). The node - * carries the whole lifecycle (structured name/args, pairing id, - * outcome-or-executing), so a - * registrant needs no second data channel; domain state arrives through its - * own projection cell. + * carries the whole lifecycle (structured name/args, pairing id, and + * outcome-or-executing). A successful domain command may also carry the + * explicitly linked projection node needed to fold two log records into one + * presentation row. */ export interface CommandRowOwnerProps { /** Folded command lifecycle node (run + optional done). */ node: CommandNode + /** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */ + compaction?: CompactionSummaryNode } /** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */ diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index df107d2cd2..11e852a8b6 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -80,6 +80,8 @@ export const zh = { 'message.context.recall.truncated': '已截断', 'message.steering': '插话', 'message.compaction': '上下文已压缩', + 'message.compaction.running': '正在压缩…', + 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', 'message.unknownSurface': '未知 surface 事件:{type}', @@ -220,6 +222,8 @@ export const en = { 'message.context.recall.truncated': 'truncated', 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', + 'message.compaction.running': 'Compacting context…', + 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', 'message.unknownSurface': 'Unknown surface event: {type}', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 3122b0fdc7..7d5ba6a528 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -691,11 +691,15 @@ describe('MessageItem arms', () => { , ) const row = view.getByRole('button', { name: /上下文已压缩/ }) expect(row.getAttribute('aria-expanded')).toBe('false') + expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() expect(view.queryByText(/保留的事实/)).toBeNull() fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') @@ -705,7 +709,10 @@ describe('MessageItem arms', () => { }) it('a marker whose provenance fell outside the window is not expandable', () => { - const view = render() + const view = render() const row = view.getByRole('button', { name: /上下文已压缩/ }) expect(row).toHaveProperty('disabled', true) expect(row.getAttribute('aria-expanded')).toBeNull() diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b8cd94de52..a0213a2407 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Profiler } from 'react' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' import type { - AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, + AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -93,6 +93,19 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, }) +const command = (over: Partial = {}): CommandNode => ({ + kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], + name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, + ...over, +}) +const compaction = (over: Partial = {}): CompactionSummaryNode => ({ + kind: 'compaction', seq: 8, time: 8_000, + summary: '## 压缩摘要\n\n保留的事实。', + summaryEventSeq: 7, + shadowedItemCount: 16, + shadowedTokenCount: 11_309, + ...over, +}) /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { @@ -212,6 +225,60 @@ describe('chat-flow derivation', () => { expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second) }) + it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => { + const running = command({ + seq: 1, + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: null, + }) + expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact') + + const settled = { + ...running, + outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 }, + } + const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 }) + const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint]) + expect(flowKeys(items)).toBe('n0|n2|ccmd-compact') + expect(items.at(-1)).toEqual({ + kind: 'command-compaction', + key: 'ccmd-compact', + command: settled, + compaction: checkpoint, + }) + }) + + it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => { + const automatic = compaction({ seq: 2, summaryEventSeq: 1 }) + expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2') + + const first = command({ + seq: 3, + commandId: 'cmd-a' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 9 }, + }) + const second = command({ + seq: 4, + commandId: 'cmd-b' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 9 }, + }) + const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 }) + expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10') + + const sole = command({ + seq: 11, + commandId: 'cmd-sole' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 12 }, + }) + const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 }) + const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 }) + expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14') + }) + it('skips render-nothing assistant nodes so tool runs stay one group', () => { // A tool-call-only step message (and blank text/reasoning) renders nothing: // it must not split the run into two groups with an empty line between. @@ -1172,11 +1239,6 @@ describe('ChatView', () => { }) it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => { - const command = (over: Partial): CommandNode => ({ - kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], - name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, - ...over, - }) // Settled success: the bare command name is the title, the outcome text // the summary — neither the dispatched `/` nor its arguments reach the row // (the settlement text already says what the command did). @@ -1211,4 +1273,62 @@ describe('ChatView', () => { expect(ov.getByText('命令')).toBeTruthy() expect(ov.getByText('已完成')).toBeTruthy() }) + + it('renders /compact as one stateful disclosure from running through completion', () => { + const running = command({ + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: null, + }) + const h = makeHarness({ nodes: [running] }) + const view = render() + expect(view.getByText('正在压缩…')).toBeTruthy() + expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() + + act(() => { + h.set({ + nodes: [{ + ...running, + outcome: { + kind: 'success', + text: 'Compacted 16 history items (~11309 tokens).', + sourceEventSeq: 7, + }, + }, compaction()], + }) + }) + + expect(view.queryByText('正在压缩…')).toBeNull() + expect(view.queryByText('上下文已压缩')).toBeNull() + expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() + const row = view.getByRole('button', { name: /compact/ }) + expect(row.getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText('保留的事实。')).toBeNull() + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('true') + expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy() + }) + + it('keeps /compact no-history and error settlements on the generic command row', () => { + const noHistory = makeHarness({ + nodes: [command({ + name: 'compact', + outcome: { kind: 'success', text: 'No compactable history yet.' }, + })], + }) + const noHistoryView = render() + expect(noHistoryView.getByText('No compactable history yet.')).toBeTruthy() + expect(noHistoryView.queryByRole('button')).toBeNull() + + const failed = makeHarness({ + nodes: [command({ + commandId: 'cmd-compact-failed' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'error', text: 'Compaction cancelled.' }, + })], + }) + const failedView = render() + expect(failedView.getByText('Compaction cancelled.')).toBeTruthy() + expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull() + }) }) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index bd25cc4d51..544199e345 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -324,7 +324,10 @@ describe('deriveTrajectoryLayout', () => { }, // A landed compaction renders no cell, but is still a real log position, // so it moves the cursor after the visible context row. - { kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' }, + { + kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts', + summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, + }, { kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0, blocks: [{ kind: 'text', text: 'done' }], diff --git a/packages/compact/command-compact/README.i18n.yaml b/packages/compact/command-compact/README.i18n.yaml index c39570db18..35ebe66e2b 100644 --- a/packages/compact/command-compact/README.i18n.yaml +++ b/packages/compact/command-compact/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compact/command-compact/README.md -README.md: a32a6aeb9957f0fd5f8cff58b1edbb9bc29a4e3d -README.zh.md: c678f522115d9b0fd414b2f290b3cb54ce690722 +README.md: 54f341e39447a423964b7d7435cfb638857eda6e +README.zh.md: d4a122b8a19cdf907212ad019b2528ae52d03886 diff --git a/packages/compact/command-compact/README.md b/packages/compact/command-compact/README.md index a32a6aeb99..54f341e394 100644 --- a/packages/compact/command-compact/README.md +++ b/packages/compact/command-compact/README.md @@ -12,7 +12,7 @@ Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The | `/compact` with no compactable history | `No compactable history yet.` — no marker or surface mutation is written. | | `/compact ` | `Usage: /compact (no arguments)` — the command takes no arguments and calls no compaction backend. | -The command is backend-independent: it depends only on `compactNow(agent, signal)`. The invoking agent is the exact target, and the dispatching UI's cancellation signal is forwarded through the seam. Every resolved invocation records the executor-owned log-only pair `command/run` / `command/done`; neither event joins model history. +The command is backend-independent: it depends only on `compactNow(agent, signal)`. The invoking agent is the exact target, and the dispatching UI's cancellation signal is forwarded through the seam. Every resolved invocation records the executor-owned log-only pair `command/run` / `command/done`; neither event joins model history. On success, `command/done.sourceEventSeq` names the transaction's `compact/summary` event so a presentation can fold the command lifecycle into its checkpoint without parsing result text or assuming adjacent rows. Expected `ManualCompactionError` codes become stable direct errors: diff --git a/packages/compact/command-compact/README.zh.md b/packages/compact/command-compact/README.zh.md index c678f52211..d4a122b8a1 100644 --- a/packages/compact/command-compact/README.zh.md +++ b/packages/compact/command-compact/README.zh.md @@ -12,7 +12,7 @@ | `/compact`,但没有可压缩历史 | `No compactable history yet.`:不会写入标记,也不会变更 surface。 | | `/compact ` | `Usage: /compact (no arguments)`:该命令不接受参数,也不会调用压缩后端。 | -该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent(智能体)就是操作的确切目标,发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。 +该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent(智能体)就是操作的确切目标,发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。成功时,`command/done.sourceEventSeq` 会指明该事务的 `compact/summary` 事件,让呈现层无须解析结果文本或假定两行相邻,即可将命令生命周期归并到对应检查点中。 预期的 `ManualCompactionError` 代码会成为稳定的直接错误: diff --git a/packages/compact/command-compact/src/index.ts b/packages/compact/command-compact/src/index.ts index 2390833bff..4ac171a689 100644 --- a/packages/compact/command-compact/src/index.ts +++ b/packages/compact/command-compact/src/index.ts @@ -68,6 +68,7 @@ async function executeCompact( return { kind: 'success', text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`, + sourceEventSeq: result.summarySeq, } } catch (error: unknown) { if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' } diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts index 71af9534e4..6922778a26 100644 --- a/packages/compact/command-compact/tests/command-compact.spec.ts +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' -import CommandService from '@deepseek-ai/dsh-commands' +import CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands' import { CompactService, ManualCompactionError, @@ -15,9 +15,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import * as commandCompact from '@deepseek-ai/dsh-command-compact' const RESULT: CompactionResult = { - startSeq: 10, - summarySeq: 11, - endSeq: 13, + startSeq: 1, + summarySeq: 2, + endSeq: 3, summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start: 1, end: 7 }, shadowedSeqs: [1, 3, 7], @@ -49,10 +49,24 @@ class StubCompactService extends CompactService { this.calls.push({ agent, signal }) if (this.operation !== undefined) return this.operation() return this.failure === undefined - ? Promise.resolve(this.result) + ? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result)) // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values. : Promise.reject(this.failure) } + + private appendResult(agent: ManualCompactAgentContext, result: CompactionResult): CompactionResult { + agent.session.append('compact/start', { turn: null }) + agent.session.append('compact/summary', { + summary: result.summary, + shadowedRange: result.shadowedRange, + shadowedSeqs: result.shadowedSeqs, + shadowedTokenCount: result.shadowedTokenCount, + provider: 'command-test', + model: 'command-test', + }) + agent.session.append('compact/end', { turn: null }) + return result + } } interface Harness { @@ -91,9 +105,11 @@ async function run( function expectLastLifecycle( test: Harness, args: string, - outcome: { readonly kind: 'success' | 'error'; readonly text?: string }, + outcome: CommandResult, ): string { - const lifecycle = test.agent.session.events.slice(-2) + const lifecycle = test.agent.session.events + .filter(event => event.type === 'command/run' || event.type === 'command/done') + .slice(-2) const runEvent = lifecycle[0] const doneEvent = lifecycle[1] if (runEvent?.type !== 'command/run' || doneEvent?.type !== 'command/done') { @@ -149,6 +165,7 @@ describe('/compact human command', () => { expect(execution.result).toEqual({ kind: 'success', text: 'Compacted 3 history items (~42 tokens).', + sourceEventSeq: RESULT.summarySeq, }) expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result)) expect(test.compact.calls).toEqual([{ agent: test.agent, signal: controller.signal }]) diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts index bbd9bcfcb1..5a5d37d8b1 100644 --- a/packages/compact/command-compact/tests/loader-composition.spec.ts +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -21,7 +21,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' const RESULT: CompactionResult = { startSeq: 1, summarySeq: 2, - endSeq: 4, + endSeq: 3, summary: [{ type: 'text', text: 'loader summary' }], shadowedRange: { start: 3, end: 8 }, shadowedSeqs: [3, 5, 8], @@ -42,9 +42,19 @@ class LoaderCompactService extends CompactService { } override compactNow( - _agent: ManualCompactAgentContext, + agent: ManualCompactAgentContext, _signal: AbortSignal, ): Promise { + agent.session.append('compact/start', { turn: null }) + agent.session.append('compact/summary', { + summary: RESULT.summary, + shadowedRange: RESULT.shadowedRange, + shadowedSeqs: RESULT.shadowedSeqs, + shadowedTokenCount: RESULT.shadowedTokenCount, + provider: 'loader-test', + model: 'loader-test', + }) + agent.session.append('compact/end', { turn: null }) return Promise.resolve(RESULT) } } @@ -108,6 +118,7 @@ describe('command-compact real Loader composition', () => { expect(execution.result).toEqual({ kind: 'success', text: 'Compacted 3 history items (~99 tokens).', + sourceEventSeq: RESULT.summarySeq, }) expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([ { @@ -119,12 +130,32 @@ describe('command-compact real Loader composition', () => { source: { kind: 'user' }, }, }, + { + type: 'compact/start', + data: { turn: null }, + }, + { + type: 'compact/summary', + data: { + summary: RESULT.summary, + shadowedRange: RESULT.shadowedRange, + shadowedSeqs: RESULT.shadowedSeqs, + shadowedTokenCount: RESULT.shadowedTokenCount, + provider: 'loader-test', + model: 'loader-test', + }, + }, + { + type: 'compact/end', + data: { turn: null }, + }, { type: 'command/done', data: { commandId: execution.commandId, kind: 'success', text: 'Compacted 3 history items (~99 tokens).', + sourceEventSeq: RESULT.summarySeq, }, }, ]) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b7fd6d3c5a..01ccdc5bf6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1793,7 +1793,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandResult', - declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', + declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n readonly sourceEventSeq?: number;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', }, { name: 'CompactAgentContext', diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml index 751344084c..be55a19ca3 100644 --- a/packages/ui/commands/README.i18n.yaml +++ b/packages/ui/commands/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/commands/README.md -README.md: 3105ae1a866e03f3c8f621bfe588df15ee38957e -README.zh.md: 704a2daefb65fde12ca85d1c9051ad762c5ccc70 +README.md: 1709bdcdce4e43d98cfea5ff3972ab95bfd3c33b +README.zh.md: 569f2aa8293793b26d63ee16e3ea7600e04a8397 diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 3105ae1a86..1709bdcdce 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -8,11 +8,11 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl `ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. -`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. +`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. `parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. -Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it. +Handlers return `success` or `error` plus optional UI text. A successful handler may also return `sourceEventSeq` when an earlier domain event owns a richer presentation; the lifecycle invariant requires that reference to be a prior non-command event in the same session. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it. ## Composition diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md index 704a2daefb..569f2aa829 100644 --- a/packages/ui/commands/README.zh.md +++ b/packages/ui/commands/README.zh.md @@ -8,11 +8,11 @@ `ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 -`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 +`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 `parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。 -处理器返回 `success` 或 `error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。 +处理器返回 `success` 或 `error`,并可附带 UI 文本。若更丰富的呈现由一条更早的领域事件持有,成功的处理器还可返回 `sourceEventSeq`;生命周期不变量要求该引用指向同一会话中更早的一条非命令事件。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。 ## 组合 diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index b6dea581eb..64a9f8e8c8 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -47,7 +47,12 @@ export interface CommandInvocation { /** Expected command outcome rendered directly by the dispatching UI. */ export type CommandResult = - | { readonly kind: 'success'; readonly text?: string } + | { + readonly kind: 'success' + readonly text?: string + /** Earlier authoritative domain event that owns a richer presentation. */ + readonly sourceEventSeq?: number + } | { readonly kind: 'error'; readonly text: string } /** @@ -140,9 +145,15 @@ declare module '@deepseek-ai/dsh-session' { /** * The paired command settled. `kind`/`text` carry the handler's verbatim * outcome (a thrown/aborted handler settles as `kind: 'error'` with the - * rendered failure); presentation stays client-computed at render time. + * rendered failure). A successful command may identify the earlier + * authoritative domain event for a richer client-computed presentation. */ - 'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string } + 'command/done': { + commandId: CommandId + kind: 'success' | 'error' + text?: string + sourceEventSeq?: number + } } } @@ -262,12 +273,20 @@ function normalizeResult(command: string, value: unknown): CommandResult { if (typeof value !== 'object' || value === null || !('kind' in value)) { throw new TypeError(`command "${command}" handler must return a CommandResult`) } - const result = value as { kind?: unknown; text?: unknown } + const result = value as { kind?: unknown; text?: unknown; sourceEventSeq?: unknown } if (result.kind === 'success') { if (result.text !== undefined && typeof result.text !== 'string') { throw new TypeError(`command "${command}" success text must be a string when supplied`) } - return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text }) + if (result.sourceEventSeq !== undefined + && (!Number.isSafeInteger(result.sourceEventSeq) || (result.sourceEventSeq as number) < 0)) { + throw new TypeError(`command "${command}" success sourceEventSeq must be a non-negative safe integer when supplied`) + } + return Object.freeze({ + kind: 'success', + ...result.text === undefined ? {} : { text: result.text }, + ...result.sourceEventSeq === undefined ? {} : { sourceEventSeq: result.sourceEventSeq as number }, + }) } if (result.kind === 'error') { if (typeof result.text !== 'string' || result.text.trim().length === 0) { @@ -389,6 +408,9 @@ export class CommandService extends Service { this.appendLifecycle(agent.session, 'command/done', { commandId, kind: result.kind, ...result.text === undefined ? {} : { text: result.text }, + ...result.kind === 'success' && result.sourceEventSeq !== undefined + ? { sourceEventSeq: result.sourceEventSeq } + : {}, }) return Object.freeze({ commandId, result }) } diff --git a/packages/ui/commands/src/invariant.ts b/packages/ui/commands/src/invariant.ts index 858c31591c..792733c199 100644 --- a/packages/ui/commands/src/invariant.ts +++ b/packages/ui/commands/src/invariant.ts @@ -34,6 +34,16 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (runIds.get(session)?.has(event.data.commandId) !== true) { fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`) } + const source = event.data.sourceEventSeq + const sourceEvent = source === undefined ? undefined : session.events[source] + if (source !== undefined + && (event.data.kind !== 'success' + || !Number.isSafeInteger(source) || source < 0 || source >= event.seq + || sourceEvent?.seq !== source + || sourceEvent.type === 'command/run' + || sourceEvent.type === 'command/done')) { + fail(`command/done ${JSON.stringify(event.data.commandId)} has invalid sourceEventSeq ${String(source)}`) + } } for (const session of ctx.sessions.list()) { for (const event of session.events) validateEvent(session, event) diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index 7412325ab6..54b4227d19 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -320,6 +320,25 @@ describe('CommandService', () => { ]) }) + it('preserves an earlier authoritative domain-event reference on successful settlement', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const source = agent.session.append('turn/start', { turn: 1 }) + ctx.commands.register({ + name: 'linked', + description: 'Link outcome', + handler: () => ({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }), + }) + + const execution = await ctx.commands.execute(agent, '/linked', new AbortController().signal) + + expect(execution?.result).toEqual({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }) + expect(lifecycleOf(agent)).toMatchObject([ + { type: 'command/run', data: { name: 'linked' } }, + { type: 'command/done', data: { kind: 'success', text: 'linked', sourceEventSeq: source.seq } }, + ]) + }) + it('omits raw input from command/run when an authoritative domain event owns it', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') @@ -427,6 +446,9 @@ describe('CommandService', () => { [null, /CommandResult/], [{}, /CommandResult/], [{ kind: 'success', text: 1 }, /success text/], + [{ kind: 'success', sourceEventSeq: -1 }, /sourceEventSeq/], + [{ kind: 'success', sourceEventSeq: 1.5 }, /sourceEventSeq/], + [{ kind: 'success', sourceEventSeq: '1' }, /sourceEventSeq/], [{ kind: 'error', text: '' }, /error text/], [{ kind: 'error', text: 1 }, /error text/], [{ kind: 'future', text: 'x' }, /unknown result kind/], diff --git a/packages/ui/commands/tests/invariant.spec.ts b/packages/ui/commands/tests/invariant.spec.ts new file mode 100644 index 0000000000..8772a3b71e --- /dev/null +++ b/packages/ui/commands/tests/invariant.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as CommandInvariant from '@deepseek-ai/dsh-commands/invariant' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import { CommandId } from '@deepseek-ai/dsh-commands' + +async function mount(installCompanion = true): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('commands-invariant')) + await ctx.plugin(InvariantService, { enabled: true }) + if (installCompanion) await ctx.plugin(CommandInvariant) + return { ctx, session } +} + +function appendRun(session: Session, id: string): void { + session.append('command/run', { + commandId: CommandId(id), + name: 'linked', + args: '', + source: { kind: 'user' }, + }) +} + +describe('command lifecycle invariants', () => { + it('accepts a success outcome linked to an earlier non-command domain event', async () => { + const { session } = await mount() + const source = session.append('turn/start', { turn: 1 }) + appendRun(session, 'cmd-valid') + + expect(() => { + session.append('command/done', { + commandId: CommandId('cmd-valid'), + kind: 'success', + sourceEventSeq: source.seq, + }) + }).not.toThrow() + }) + + it.each([-1, 1.5, 1])('rejects invalid or command-owned sourceEventSeq %s', async (sourceEventSeq) => { + const { session } = await mount() + appendRun(session, 'cmd-invalid') + + expect(() => { + session.append('command/done', { + commandId: CommandId('cmd-invalid'), + kind: 'success', + sourceEventSeq, + }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-commands', + })) + }) + + it('rejects an error settlement carrying a success-only source reference', async () => { + const { session } = await mount() + const source = session.append('turn/start', { turn: 1 }) + appendRun(session, 'cmd-error-source') + + expect(() => { + session.append('command/done', { + commandId: CommandId('cmd-error-source'), + kind: 'error', + text: 'failed', + sourceEventSeq: source.seq, + }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-commands', + })) + }) + + it('attributes an invalid durable prefix during late companion loading', async () => { + const { ctx, session } = await mount(false) + appendRun(session, 'cmd-late') + session.append('command/done', { + commandId: CommandId('cmd-late'), + kind: 'success', + sourceEventSeq: 0, + }) + + await expect(ctx.plugin(CommandInvariant)).rejects.toMatchObject({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-commands', + }) + }) +}) From f32aa54aeb0b526c6c04dd1212cce33e8751afa5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:07:11 +0800 Subject: [PATCH 050/100] feat(cli)!: make dsh run the headless entrypoint --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 6 +- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 6 +- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +- .../2026-08-05-profile-plugin-bundles.md | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 2 +- ...3-cli-signal-shutdown-escalation.i18n.yaml | 4 +- ...26-08-03-cli-signal-shutdown-escalation.md | 4 +- ...08-03-cli-signal-shutdown-escalation.zh.md | 4 +- ...6-08-08-dsh-run-headless-command.i18n.yaml | 6 ++ .../2026-08-08-dsh-run-headless-command.md | 39 ++++++++++ .../2026-08-08-dsh-run-headless-command.zh.md | 39 ++++++++++ ...3-explicit-config-dsh-entrypoint.i18n.yaml | 2 +- ...08-03-explicit-config-dsh-entrypoint.zh.md | 2 +- README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 4 +- apps/cli/README.zh.md | 4 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 10 ++- apps/cli/reference/README.zh.md | 10 ++- apps/cli/src/args.ts | 58 ++++++++++----- apps/cli/src/bin.ts | 11 ++- apps/cli/src/profile-boot.ts | 6 +- apps/cli/tests/args.spec.ts | 18 ++++- apps/cli/tests/built-bin.e2e.ts | 44 +++++++++++- apps/cli/tests/headless-shutdown.e2e.ts | 4 +- docs/config-catalog.md | 2 +- .../tests/fixtures/dsh-run.cordis.yml | 8 +++ .../headless-agent/tests/headless.snapshot.ts | 65 +++++++++++++++-- .../snapshots/dsh-run/session.expected.jsonl | 33 +++++++++ packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/src/index.ts | 25 ++++--- .../bundle/headless/tests/headless.spec.ts | 72 ++++++++++++++----- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- 41 files changed, 429 insertions(+), 101 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md create mode 100644 examples/headless-agent/tests/fixtures/dsh-run.cordis.yml create mode 100644 examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index bdb07d5f8a..65cbe478ae 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 34077302c53081f6ee9171d64dce9af342710d71 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: bc51542ac8159ee7cba234b4ee8b4db47a7f9b58 +2026-07-19-gui-layering-and-rpc-protocol.md: 8e020e4fe9b60100671c0cf0e98e28532d850f94 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 55fa8084083aa83fbb4a38f8e41d2e5624b6e58e diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 34077302c5..8e020e4fe9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -10,7 +10,7 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities: -- One `dsh` process supporting both `dsh web` (serve) and `dsh -p` (headless) — one process, two modes (a design reservation) +- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation) - Launching inside Electron with the same Web technology shape as `dsh web` That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh run` = headless in-process calls, zero HTTP. - A future Electron shape reuses the same web client packages over an IPC fetch carrier. ``` @@ -215,7 +215,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| -| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer | +| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh run` headless is the protocol's second real consumer | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index bc51542ac8..55fa808408 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -9,7 +9,7 @@ Status: implemented ## Problem 需要提供 UI 对接层,除已有 ACP/stdio基础版本外,还需要 Web(server) 、 Electron 、等其他产品 UI 形态。我们把这些形态统一称为 Client。希望有如下能力支持: -- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh -p`(headless) ,一个进程两种模式(设计预留) +- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh run`(headless) ,一个进程两种模式(设计预留) - 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh -p` = headless 进程内直调,零 HTTP。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = headless 进程内直调,零 HTTP。 - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -213,7 +213,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| -| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 | +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh run` headless 即协议第二真实消费者 | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index eed6bee5f0..7fa37eeb8e 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 11a8ac3d4005371ca9596ba237aaf42a8e770dee -2026-08-05-profile-plugin-bundles.zh.md: 0e9ebf657ccb9d05967d90a935b356acf287a24c +2026-08-05-profile-plugin-bundles.md: b5bf5411d22ab99b598f667886b3c29ba8ee7b06 +2026-08-05-profile-plugin-bundles.zh.md: ae790028b5768c05c57acd27d7f68bdc4d612c11 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 11a8ac3d40..b5bf5411d2 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. -The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh --profile headless "task"` replaces `-p`; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). +The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile, while generic `dsh --profile ` boots without a task; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 0e9ebf657c..ae790028b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 -已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh --profile headless "task"` 取代 `-p`;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 +已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile,而通用的 `dsh --profile ` 只启动 profile,不携带任务;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml index 59e98bc061..4752010ef6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md -2026-08-03-cli-signal-shutdown-escalation.md: 2746b5784baad0f3b14258280cd56a621db07c15 -2026-08-03-cli-signal-shutdown-escalation.zh.md: 0bda83327d4cc8fe2edb61f8145a89138610901e +2026-08-03-cli-signal-shutdown-escalation.md: 7c9715c37ee57be9fa0f67af0c19f0bfa84845da +2026-08-03-cli-signal-shutdown-escalation.zh.md: f3485edc9e453c0b774f442bfce6d678d63f2224 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md index 2746b5784b..7c9715c37e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md @@ -6,9 +6,9 @@ English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md) ## Problem -The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and `dsh -p` so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. +The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh run`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. -A user then reproduced `dsh -p` hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. +A user then reproduced the headless command hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. The latch then turned that telemetry defect into an unkillable CLI: normal completion was already awaiting the single-shot root disposal; the first SIGINT joined the same pending disposal and set the signal latch; later SIGINTs returned at the latch, so the process had no remaining escape. A signal received before normal completion had the same unbounded wait. Web used the same latch shape. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md index 0bda83327d..f3485edc9e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md @@ -6,9 +6,9 @@ ## 问题 -默认挂载遥测后,`dsh web` 与 `dsh -p` 新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 +默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh run`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 -随后有用户复现,`dsh -p` 在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 +随后有用户复现,headless 命令在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 闩锁随后把这个遥测缺陷变成无法终止的 CLI(命令行界面):正常完成流程已经在等待单次根级 dispose;第一次 SIGINT 会加入同一个待结算的 dispose,并设置信号闩锁;后续 SIGINT 在闩锁处直接返回,因此进程再无退出途径。正常完成之前收到信号时,同样会陷入无界等待。Web 使用的闩锁结构与此相同。 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml new file mode 100644 index 0000000000..8d5ec1c9f6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +2026-08-08-dsh-run-headless-command.md: aac2a473760509626d315df8d57eb405eb547abf +2026-08-08-dsh-run-headless-command.zh.md: d71d2a34addf1c64b8cb37c54117be5b9c643370 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md new file mode 100644 index 0000000000..aac2a47376 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -0,0 +1,39 @@ +# Agent Note: `dsh run` owns one-shot headless execution + +Status: implemented + +English | [中文](2026-08-08-dsh-run-headless-command.zh.md) + +## Problem + +The product launcher attached optional task text to its generic profile boot: `dsh --profile headless "task"`. That made one argv shape mean either a long-lived profile or a one-shot run according to a row discovered only after composition. The parser's `ProfileInvocation` carried optional task state, help presented a profile implementation detail as the user command, and a custom profile could accept a task only through the same overloaded root. + +The former `dsh -p` spelling was already absent from the parser, so restoring it or detecting it specially would add compatibility machinery to a pre-release interface. A separate application-file proposal also used the `run` verb, leaving two incompatible owners for one top-level command. + +## Decision + +One-shot execution owns an explicit grammar: + +```text +dsh run [--profile ] [--patch ...] +``` + +`--profile` defaults to `headless` and remains available for custom one-shot compositions. `--patch` is repeatable and occupies the existing overlay layer. Commander joins the variadic task arguments with spaces and rejects a missing or blank task before boot. + +`RunInvocation` is a separate `DshInvocation` member. The generic profile invocation no longer carries task text, and its root command accepts no positional arguments. Both dispatch paths call the existing deep `runProfile` module: `profile` omits `task`, while `run` supplies it. There is no shallow `run.ts` forwarding module and no alias, warning, or custom detector for former spellings; they fail through the ordinary Commander grammar. A one-shot profile without `headless-runner` still fails through the existing composed-row check, while booting a profile that contains that row without a task points to `dsh run --profile ""`. + +The `run` verb belongs to one-shot task execution. Launching an application file must choose another command name; two top-level meanings selected by positional shape would recreate the ambiguity this command removes. + +The runner's user-visible contract stays the same: a fresh persisted session, browser observation URL on stderr, final assistant text on stdout, completed/non-completed exit mapping, and bounded signal shutdown. The product-level keyless acceptance exposed that the in-process mux consumer could lag the same-process `agent/status: idle` notification and derive output before reading the final frames. The idle notification now captures the authoritative final session sequence, and the runner waits until the ordered mux reaches that boundary (or the stream ends) before deriving text and exit reason. This enforces the existing idle-to-idle contract without adding a wire field or a timing delay. + +## Alternatives considered + +- **Keep task text on `dsh --profile`.** Rejected because profile boot and one-shot execution remain one grammar whose meaning depends on a late composition check. +- **Preserve `dsh -p` or the positional profile form as aliases.** Rejected under the pre-release stance: compatibility branches would outlive the interface they were meant to retire. +- **Make `--profile headless` mandatory under `run`.** Rejected because the shipped one-shot surface should have the shortest canonical spelling, while optional `--profile` preserves plugin-defined one-shot compositions. +- **Give `dsh run` to application-file launch and choose another headless verb.** Rejected because `run` describes executing a task through the harness; application-file ownership would make the product's primary one-shot command less direct and collide with custom one-shot profiles. +- **Add `apps/cli/src/run.ts`.** Rejected because it would only forward to `runProfile`, splitting command ownership without hiding any complexity. + +## Consequences + +This is an intentional breaking CLI change. Documentation, help, parser tests, built-bin acceptance, PTY shutdown coverage, and the assembled keyless snapshot use `dsh run`. Existing custom one-shot profiles keep working through `--profile`; long-lived profiles and config dumps retain their existing root grammar. The competing application-file command must be renamed and rebased separately rather than sharing or overloading `run`. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md new file mode 100644 index 0000000000..d71d2a34ad --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -0,0 +1,39 @@ +# Agent Note: `dsh run` 负责一次性 headless 执行 + +Status: implemented + +[English](2026-08-08-dsh-run-headless-command.md) | 中文 + +## 问题 + +产品启动器过去把可选任务文本挂在通用 profile 启动命令上:`dsh --profile headless "task"`。于是,同一种 argv 形态会表示常驻 profile 或一次性运行,具体含义取决于组合完成后才发现的配置行。解析器的 `ProfileInvocation` 携带可选任务状态,帮助信息把 profile 的实现细节呈现为用户命令,自定义 profile 也只能通过同一个过载的根命令接收任务。 + +解析器中已经没有原来的 `dsh -p` 写法,因此恢复该写法或加入特殊检测,会给预发布接口增加兼容机制。另一个应用文件提案也使用 `run` 动词,使同一个顶层命令同时归属两个互不兼容的功能。 + +## 决策 + +一次性执行采用明确语法: + +```text +dsh run [--profile ] [--patch ...] +``` + +`--profile` 默认为 `headless`,同时保留对自定义一次性组合的支持。`--patch` 可重复使用,并沿用既有 overlay 层的位置。Commander 用空格拼接可变数量的任务参数,并在启动前拒绝缺失或空白任务。 + +`RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不再携带任务文本,其根命令也不接受位置参数。两条分派路径都调用已有的深层 `runProfile` 模块:`profile` 省略 `task`,`run` 则提供该字段。实现中没有只负责转发的浅层 `run.ts` 模块,也没有面向旧写法的别名、警告或自定义检测器;旧写法会按普通 Commander 语法失败。缺少 `headless-runner` 的一次性 profile 仍会触发既有的组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 + +`run` 动词只负责一次性任务执行。应用文件启动必须选择其他命令名;如果让两个顶层含义由位置参数形态决定,就会重新引入本命令消除的歧义。 + +运行器面向用户的契约保持不变:创建新的持久化会话,在 stderr 打印浏览器观察 URL,在 stdout 打印最终 assistant 文本,将完成/未完成映射为退出状态,并执行有界的信号关闭。产品级无密钥验收用例发现,进程内 mux 消费方可能落后于同进程的 `agent/status: idle` 通知,在读到最终帧之前就生成输出。idle 通知现在会捕获权威的会话最终事件序号,运行器则等待有序 mux 到达该边界(或流结束),再生成文本和退出原因。这一机制在不增加 wire 字段或定时延迟的前提下,落实了既有的 idle-to-idle 契约。 + +## 考虑过的替代方案 + +- **把任务文本保留在 `dsh --profile` 上。** 不予采纳:profile 启动和一次性执行仍共用同一套语法,其含义取决于较晚发生的组合检查。 +- **保留 `dsh -p` 或位置参数 profile 形式作为别名。** 不予采纳:根据预发布立场,这些兼容分支会比本应退役的接口存续更久。 +- **要求在 `run` 下必须指定 `--profile headless`。** 不予采纳:已交付的一次性接口应采用最短的规范写法,同时用可选的 `--profile` 保留插件定义的一次性组合。 +- **把 `dsh run` 交给应用文件启动,并为 headless 选择另一个动词。** 不予采纳:`run` 描述的是通过 harness 执行任务;若归应用文件所有,产品的主要一次性命令会更不直接,并与自定义一次性 profile 冲突。 +- **新增 `apps/cli/src/run.ts`。** 不予采纳:它只会转发到 `runProfile`,拆分命令归属,却没有隐藏任何复杂度。 + +## 后果 + +这是一次有意为之的 CLI(命令行界面)破坏性变更。文档、帮助信息、解析器测试、构建后二进制验收、PTY 关闭覆盖和组装应用的无密钥快照都使用 `dsh run`。现有自定义一次性 profile 可继续通过 `--profile` 工作;常驻 profile 和配置 dump 保留既有的根命令语法。与之竞争的应用文件命令必须单独改名并 rebase,不得共享或重载 `run`。 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml index 8ff1af7e8e..ee43f9465c 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md 2026-08-03-explicit-config-dsh-entrypoint.md: e0d1e954d9cef472ea59345a3d2ef5a67bd03ae8 -2026-08-03-explicit-config-dsh-entrypoint.zh.md: b5b464e3b45a6f3909bbf087f7005ad3f819424a +2026-08-03-explicit-config-dsh-entrypoint.zh.md: 614c2d8600731d85c83d6559bc577350da25e872 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md index b5b464e3b4..614c2d8600 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md @@ -1,4 +1,4 @@ -# Agent Note:显式配置的 dsh 入口 +# Agent Note: 显式配置的 dsh 入口 Status: implemented diff --git a/README.i18n.yaml b/README.i18n.yaml index 0a7c3e49fe..c4ad1f0fd9 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: d8d3e767d5a9805f34f4df57a5b1f8ff7fdaa955 -README.zh.md: 89abf8d817deeed2bf4416035790c8696c8c8e33 +README.md: 64f06c0aec0905fa7deabbec0deea61e1c7a40d4 +README.zh.md: fee03118926028833c828809764ebb5f6375259e diff --git a/README.md b/README.md index d8d3e767d5..64f06c0aec 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -dsh --profile headless "summarize this workspace" +dsh run "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index 89abf8d817..fee0311892 100644 --- a/README.zh.md +++ b/README.zh.md @@ -56,7 +56,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -dsh --profile headless "summarize this workspace" +dsh run "summarize this workspace" ``` ### 自动化与 SDK diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index cbc74b6d0a..801560e7d7 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: f50f26ec5de54e094e17221f7cd355483483754e -README.zh.md: 242e64a0c42064b9f0b7621e665e85fe44523fe5 +README.md: 12108fcbff4e649d0bcb3e01e688fa334ab91b14 +README.zh.md: 9518feed1d5d40c3e5ec2d346b929118ccc08810 diff --git a/apps/cli/README.md b/apps/cli/README.md index f50f26ec5d..12108fcbff 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -9,11 +9,11 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin | Command | Purpose | |---|---| | `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | -| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh run [--profile ] [--patch ...] "task"` | Run one fresh persisted session, print the final answer, and exit; the profile defaults to `headless`. | | `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. ## Profiles diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 242e64a0c4..9518feed1d 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -9,11 +9,11 @@ | 命令 | 用途 | |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | -| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh run [--profile ] [--patch ...] "task"` | 运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`。 | | `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 ## Profile diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index e64141c31d..27e391320a 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: c7c7b2aa231d4c9f4b3fbf31663237c8457eb051 -README.zh.md: 5439aa78b74415c8e6264d21f5c52e5cee5b38ee +README.md: 496cecdb64e3254a2a77690f55f760b4cd90b521 +README.zh.md: 4673bf764347307a9b91e2a5474a8439cf67b481 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index c7c7b2aa23..496cecdb64 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. +This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. ## Profile boot @@ -12,7 +12,7 @@ Bundle names resolve from the dsh installation first, then from the profile dire The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + web-app + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. -A positional task (`dsh --profile headless "run the tests"`) requires the composition to mount the one-shot runner row (`headless-runner`); the launcher patches the task text into that row, the runner drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser. +Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile ""` command instead of reaching the row's raw required-field error. Inspect the composed tree without booting it: @@ -23,6 +23,12 @@ dsh --profile web --patch ./extra.yml --dump-config `--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print provenance comments per layer; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. +## One-shot run + +`dsh run [--profile ] [--patch ...] ` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row. + +The launcher patches the task text into the runner row, which drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. At the idle boundary, the runner waits until its mux consumer has observed the session's final event sequence before deriving that output and exit reason. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser. + ## Plugin management `dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 5439aa78b7..4673bf7643 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 +本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 ## Profile 启动 @@ -12,7 +12,7 @@ `web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + web-app + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 -位置参数任务(`dsh --profile headless "run the tests"`)要求组合挂载一次性运行器行(`headless-runner`);启动器把任务文本 patch 进该行,运行器通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。 +Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile ""`,而不会触发该行原始的必填字段错误。 可在不启动的情况下检查组合出的配置树: @@ -23,6 +23,12 @@ dsh --profile web --patch ./extra.yml --dump-config `--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会按层打印来源注释;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 +## 一次性运行 + +`dsh run [--profile ] [--patch ...] ` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。 + +启动器把任务文本 patch 进运行器行,运行器再通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。到达 idle 边界时,运行器会等到 mux 消费方观察到会话的最终事件序号,再生成输出与退出原因。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。 + ## 插件管理 `dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,`dsh.profile.bundles` 都会与已安装状态对齐:每个解析到 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的包的依赖加入层栈(因此让包获得该声明的 `update` 会将其激活),没有组合包声明的依赖保持为普通依赖并给出一次性警告,已移除的依赖则退出层栈。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 310b5b03a2..0b72f76c6a 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,10 +1,10 @@ /** * Commander adapter for the `dsh` command-line entry. The default command * boots a named profile (`--profile `), optionally with extra `--patch` - * overlays and a positional task (one-shot mode for profiles mounting the - * headless runner). `web` is a hardcoded alias for `--profile web` that adds - * the Web flag family; `plugin` manages a profile's plugin dependencies by - * forwarding to pnpm. Commander owns help, version, and parse errors. + * overlays. `run` owns one-shot task execution, defaulting to the headless + * profile; `web` is a hardcoded alias for `--profile web` that adds the Web + * flag family; `plugin` manages a profile's plugin dependencies by forwarding + * to pnpm. Commander owns help, version, and parse errors. * @module @deepseek-ai/dsh/args */ @@ -16,8 +16,16 @@ interface ProfileInvocation { profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] - /** Positional task text joined by spaces; non-empty only for one-shot runs. */ - task?: string +} + +/** Run one task through a profile mounting the headless runner. */ +interface RunInvocation { + mode: 'run' + profile: string + /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ + patches: string[] + /** Non-blank task text joined from the variadic positional arguments. */ + task: string } /** Print a composed profile tree and exit without booting. */ @@ -54,7 +62,7 @@ interface PluginInvocation { } /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = ProfileInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation +export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { @@ -68,6 +76,12 @@ interface WebOptions { dumpDefaultConfig?: boolean } +/** Raw run-subcommand options straight from Commander. */ +interface RunOptions { + profile?: string + patch?: string[] +} + /** * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never * variadic — a variadic `--patch` would swallow a following positional task. @@ -90,19 +104,19 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .addHelpText('after', ` Examples: dsh --profile web boot the web profile (same as: dsh web) - dsh --profile headless "run the tests" answer one task, print the result, and exit + dsh run "run the tests" answer one task, print the result, and exit + dsh run --profile custom "run the tests" run one task through a custom one-shot profile dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay dsh plugin --profile tui add install a plugin into the tui profile dsh web --port 8080 the web alias with its flag family `) .exitOverride() .enablePositionalOptions() - .argument('[task...]', 'one-shot task text for profiles mounting the headless runner') .option('--profile ', 'the profile under $DSH_HOME/profiles to boot') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) .option('--dump-config', 'print the composed profile tree and exit') .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit') - .action((task: string[], options: { + .action((options: { profile?: string patch?: string[] dumpConfig?: boolean @@ -116,7 +130,6 @@ Examples: if (options.dumpConfig === true && options.dumpDefaultConfig === true) { program.error('error: --dump-config and --dump-default-config are mutually exclusive') } - if (task.length > 0) program.error('error: --dump-config/--dump-default-config take no task') const defaultOnly = options.dumpDefaultConfig === true if (defaultOnly && patches.length > 0) { program.error('error: --dump-default-config prints the bundle layers and takes no --patch') @@ -124,12 +137,7 @@ Examples: resolved = { mode: 'dump-config', profile, defaultOnly, patches } return } - resolved = { - mode: 'profile', - profile, - patches, - ...task.length > 0 ? { task: task.join(' ') } : {}, - } + resolved = { mode: 'profile', profile, patches } }) /** Reject parent options that crossed a subcommand boundary. */ @@ -146,6 +154,22 @@ Examples: } } + const run = program.command('run').description('run one task through a profile mounting the headless runner') + run + .option('--profile ', 'one-shot profile under $DSH_HOME/profiles', 'headless') + .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) + .argument('', 'task text') + .action((task: string[], options: RunOptions) => { + rejectParentOptions('run') + const profile = options.profile ?? 'headless' + if (profile === '') program.error('error: --profile needs a name') + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + const joined = task.join(' ') + if (joined.trim() === '') program.error('error: run needs a non-blank task') + resolved = { mode: 'run', profile, patches, task: joined } + }) + const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port') web .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 4a209b2796..b332a64615 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -33,7 +33,16 @@ switch (invocation.mode) { environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, - ...invocation.task !== undefined && { task: invocation.task }, + }) + break + } + case 'run': { + const { runProfile } = await import('./profile-boot.ts') + await runProfile({ + environment: loadLayeredEnv('dsh'), + profile: invocation.profile, + patchFiles: invocation.patches, + task: invocation.task, }) break } diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 742bf29db4..4730ec7073 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -47,7 +47,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ const TELEMETRY_ROW_ID = 'telemetry-otel' -/** The one-shot runner row a positional task requires and configures. */ +/** The one-shot runner row a `dsh run` task requires and configures. */ const HEADLESS_ROW_ID = 'headless-runner' /** The empty root entry list every profile tree patches over. */ @@ -160,7 +160,7 @@ export interface RunProfileOptions { patchFiles: readonly string[] /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */ deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[] - /** One-shot task text; requires the composition to mount the headless runner row. */ + /** `dsh run` task text; requires the composition to mount the headless runner row. */ task?: string /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ prepare?: (ctx: Context, rows: ProfileRows) => Promise | void @@ -190,7 +190,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // error naming no fix. throw new Error( `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: ` - + `dsh --profile ${options.profile} ""`, + + `dsh run --profile ${options.profile} ""`, ) } diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 93bfb62cc6..c9b3dc18f1 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -21,12 +21,16 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes profile boots, one-shot tasks, and the web alias', () => { + it('routes profile boots, one-shot runs, and the web alias', () => { expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] }) - expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) - .toEqual({ mode: 'profile', profile: 'headless', patches: [], task: 'run the tests' }) expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml'])) .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) + expect(parse(['run', 'run', 'the', 'tests'])) + .toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' }) + expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests'])) + .toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' }) + expect(parse(['run', '--', '--profile', 'is', 'task', 'text'])) + .toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) @@ -65,6 +69,13 @@ describe('parseDshArgs', () => { expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed expect(exitCode(['-p', 'task'])).toBe(1) // removed + expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run` + expect(exitCode(['run'])).toBe(1) + expect(exitCode(['run', ''])).toBe(1) + expect(exitCode(['run', '--profile', '', 'task'])).toBe(1) + expect(exitCode(['run', '--patch=', 'task'])).toBe(1) + expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1) + expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1) expect(exitCode(['--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', '--patch='])).toBe(1) expect(exitCode(['--dump-config'])).toBe(1) @@ -90,6 +101,7 @@ describe('parseDshArgs', () => { it('exits 0 for help and version', () => { expect(exitCode(['--help'])).toBe(0) + expect(exitCode(['run', '--help'])).toBe(0) expect(exitCode(['--version'])).toBe(0) }) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 20ed3fb160..a42780581f 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -182,14 +182,56 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const help = await runBuiltBin(['--help']) expect(help.code).toBe(0) expect(help.stdout).toContain('dsh --profile web') + expect(help.stdout).toContain('dsh run "run the tests"') expect(help.stdout).toContain('dsh plugin --profile') expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) - for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) { + for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) { const result = await runBuiltBin(removed) expect(result.code).toBe(1) } }, 30_000) + it('prints run help without initializing the selected profile', async () => { + const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-')) + const home = join(parent, 'not-created') + try { + const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home }) + expect(result.code).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('Usage: dsh run [options] ') + expect(existsSync(home)).toBe(false) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('runs the default headless profile through the published run command', async () => { + const apiKey = 'built-dsh-run-key' + const server = await startMockLlmServer({ + sequence: ['success'], + apiKey, + successText: 'published dsh run reached the mock', + }) + const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-')) + try { + const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: apiKey, + DEEPSEEK_BASE_URL: server.baseURL, + }) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toBe('published dsh run reached the mock') + expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+$/u) + expect(server.requests.length).toBeGreaterThan(0) + expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) + expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry') + } finally { + await server.close() + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + it('does not load a project environment for --version', async () => { const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-')) writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n') diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index cfa87b03de..237554864c 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -66,7 +66,7 @@ async function runHeadlessPtySmoke(): Promise { try { const home = join(cwd, '.dsh') // Pre-initialize the headless profile with the never-dispose row in its - // user patch layer (the same file `dsh --profile headless` hot-reloads). + // user patch layer (the same file a long-lived profile boot hot-reloads). const profileDir = join(home, 'profiles', 'headless') await mkdir(profileDir, { recursive: true }) await writeFile(join(profileDir, 'package.json'), JSON.stringify({ @@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise { ].join('\n')) const launch = resolveExampleLaunch({ srcBin: dshBinScript, - configArgs: ['--profile', 'headless', 'never complete'], + configArgs: ['run', 'never complete'], tsconfigPath, env: { DSH_HOME: home, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 42c2f52565..eda24bb29f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -509,7 +509,7 @@ export interface Config { } ``` -Source: [`packages/bundle/headless/src/index.ts:33`](../packages/bundle/headless/src/index.ts) +Source: [`packages/bundle/headless/src/index.ts:32`](../packages/bundle/headless/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml new file mode 100644 index 0000000000..e67630c029 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml @@ -0,0 +1,8 @@ +- id: api-gateway + config: + provider: cli-mock + model: cli-mock + +- insert: + - id: cli-mock-llm + name: !!js process.env.DSH_RUN_MOCK_PLUGIN_URL diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index f9cb46111a..9145c52f5a 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -2,7 +2,7 @@ import { readFile, readdir, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { delimiter, dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { normalizeSessionLog, normalizeStdout, @@ -14,6 +14,10 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { + decompressZstdFrame, + scanZstdFrames, +} from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.ts' import { describe, expect, it } from 'vitest' const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') @@ -44,9 +48,15 @@ const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', im const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt') const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) +const dshRunOverlayPath = fileURLToPath(new URL('./fixtures/dsh-run.cordis.yml', import.meta.url)) +const dshRunSessionExpected = join(snapshotsDir, 'dsh-run', 'session.expected.jsonl') +const cliMockLlmPluginUrl = pathToFileURL( + fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)), +).href const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -167,16 +177,61 @@ async function scenarioPrompt(dir: string, label: string): Promise { return prompt } -async function persistedLogs(cwd: string): Promise { - const root = join(cwd, '.sessions') - const files = (await readdir(root, { recursive: true })).filter(file => file.endsWith('.jsonl')) +async function readPersistedLog(file: string): Promise { + const content = await readFile(file) + if (!file.endsWith('.zstd')) return content.toString('utf8') + const scan = scanZstdFrames(content) + if (scan.tornStart !== undefined) throw new Error(`persisted snapshot log has a torn Zstandard frame: ${file}`) + const decoded: Buffer[] = [] + for (const frame of scan.frames) { + decoded.push(await decompressZstdFrame(content.subarray(frame.start, frame.end))) + } + return Buffer.concat(decoded).toString('utf8') +} + +async function persistedLogs(cwd: string, root: string = join(cwd, '.sessions')): Promise { + const files = (await readdir(root, { recursive: true })) + .filter(file => file.endsWith('.jsonl') || file.endsWith('.jsonl.zstd')) return Promise.all(files.map(async (file) => { - const content = await readFile(join(root, file), 'utf8') + const content = await readPersistedLog(join(root, file)) return { content, header: parseJsonl(content)[0] ?? {} } })) } describe('headless stream-json snapshots', () => { + it('runs one task through the product dsh run command', async () => { + const task = 'Prove the product dsh run path with one real tool round trip.' + const result = await runLoaderSmoke({ + label: 'product dsh run snapshot', + tempDirPrefix: 'headless-snapshot-dsh-run-', + binScript: dshBinScript, + configPath: dshRunOverlayPath, + binArgs: ['run', '--patch', dshRunOverlayPath, task], + tsconfigPath, + env: { + DSH_RUN_MOCK_PLUGIN_URL: cliMockLlmPluginUrl, + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_TELEMETRY_DISABLED: '1', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) + expect(logs).toHaveLength(1) + const actual = logs[0] + if (actual === undefined) throw new Error('dsh run did not persist its session') + const context = contextFromLogs([actual.content]) + const session = scrubRequestHeaders(normalizeSessionLog(actual.content, context)) + if (refreshing) await writeFile(dshRunSessionExpected, session) + expect(session).toBe(await readFile(dshRunSessionExpected, 'utf8')) + expect(session).toContain(task) + expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP') + }, + }) + + expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n') + expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+\n$/u) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('prints the original Loader activation error through the assembled one-shot app', async () => { const result = await runLoaderSmoke({ label: 'headless startup activation error snapshot', diff --git a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl b/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl new file mode 100644 index 0000000000..1312eb6511 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","seq":1,"time":0,"data":{"mode":"danger-full-access"}} +{"type":"approval/policy","seq":2,"time":0,"data":{"policy":"never"}} +{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user","rpcId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user","rpcId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product dsh run","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":0,"data":{"provider":"cli-mock","model":"cli-mock"}} +{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product dsh run path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"tool/call","seq":19,"time":0,"data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} +{"type":"tool/result","seq":20,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":23,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} +{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}}} +{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}}}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":31,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 08e4a5a5b5..f1a9d53be8 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: d08fb08e2aca3c4e5ccd733b37fc415d492974ca -README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0 +README.md: 661b377817482d22f58f22b573075722646729a2 +README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index d08fb08e2a..661b377817 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh --profile headless "task"`), and fails loud when a task is given to a profile without this row. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 99a64ef04c..a6b91a8e60 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "task"`);如果向没有这一行的 profile 传入任务,则大声失败。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout,并经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 ## 模型体验 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 572f11b487..8db505c3ac 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -6,8 +6,7 @@ * (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire * chain — serialization, zod, SSE framing — really runs), prints the final * assistant text at agent quiescence, and exits (completed → 0, else 1). The - * task text arrives as launcher-patched config - * (`dsh --profile headless "task"`). + * task text arrives as launcher-patched config (`dsh run "task"`). * @module @deepseek-ai/dsh-headless */ @@ -86,26 +85,31 @@ async function unwrap(response: RpcResponse, io: HeadlessIo): Promise { * `agent/status` subscription; the stream itself carries no status frame. * @param frames - the mux stream opened before the prompt. * @param sessionId - the headless session. - * @param idle - resolves when the agent reaches quiescence. + * @param idle - resolves to the final session-event sequence when the agent reaches quiescence. * @param io - process-facing effects for stream diagnostics. * @returns the aggregated outcome. */ async function consumeUntilIdle( frames: AsyncIterable>, sessionId: SessionId, - idle: Promise, + idle: Promise, io: HeadlessIo, ): Promise { let started = false let text = '' let reason: string = 'error' - void (async () => { + let observedSeq = -1 + let resolveProgress: (() => void) | undefined + const streamDone = (async () => { try { for await (const frame of frames) { const payload = frame.payload if (payload.type === 'stream/error') return if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue const event = payload.event + observedSeq = event.seq + resolveProgress?.() + resolveProgress = undefined if (event.type === 'turn/start') { started = true continue @@ -121,7 +125,12 @@ async function consumeUntilIdle( io.stderr.write(`dsh: event stream failed: ${String(error)}\n`) } })() - await idle + const streamEnded = streamDone.then(() => 'ended' as const) + const idleSeq = await idle + while (observedSeq < idleSeq) { + const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } }) + if (await Promise.race([progress, streamEnded]) === 'ended') break + } return { text, reason } } @@ -154,9 +163,9 @@ export function apply(ctx: Context, config: Config): void { // port of this runner must replace it with a wire-visible idle signal. const abort = new AbortController() const frames = api.events.mux({}, abort.signal) - const idle = new Promise((resolve) => { + const idle = new Promise((resolve) => { ctx.on('agent/status', ({ agent, status }) => { - if (agent.id === created.sessionId && status === 'idle') resolve() + if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1) }) }) const done = consumeUntilIdle(frames, created.sessionId, idle, io) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 6ff96619ff..9408ee62bf 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -21,26 +21,45 @@ function stamped(event: ScriptedEvent): ScriptedEvent { interface RpcShapedRequest { rpcId: string } +interface ScriptedApiOptions { + promptFails?: boolean + framesAfterPrompt?: boolean + onPrompt?: () => void +} + /** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */ -function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): unknown { +function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown { + let releaseFrames = (): void => {} + const framesReady = options.framesAfterPrompt === true + ? new Promise((resolve) => { releaseFrames = resolve }) + : Promise.resolve() + const prepared = events.map((event) => { + if (event.type === 'stream/error') return { streamError: true } as const + const { sessionId = 'S1', ...rest } = event + return { streamError: false, sessionId, event: stamped(rest) } as const + }) return { sessions: { create: (request: RpcShapedRequest) => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }), - prompt: (request: RpcShapedRequest) => Promise.resolve(options.promptFails === true - // A code from the closed wire union: the carrier schema rejects invented codes. - ? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } } - : { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }), + prompt: (request: RpcShapedRequest) => { + releaseFrames() + options.onPrompt?.() + return Promise.resolve(options.promptFails === true + // A code from the closed wire union: the carrier schema rejects invented codes. + ? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } } + : { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }) + }, }, events: { mux: async function* () { - for (const event of events) { - if (event.type === 'stream/error') { + await framesReady + for (const item of prepared) { + if (item.streamError) { yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } } continue } - const { sessionId = 'S1', ...rest } = event - yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } } + yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } } } }, }, @@ -51,7 +70,10 @@ function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } * Mount the runner against a scripted API, emit the idle transition after the * scripted frames drain, and wait for its exit request. */ -async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): Promise<{ code: number; out: string; err: string }> { +async function run( + events: ScriptedEvent[], + options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {}, +): Promise<{ code: number; out: string; err: string }> { const ctx = new Context() let out = '' let err = '' @@ -63,16 +85,25 @@ async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = } ctx.provide('headlessIo', io) }) - ctx.provide('apiProxy', scriptedApi(events, options) as never) + const emitIdle = (): void => { + ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' }) + } + ctx.provide('apiProxy', scriptedApi(events, { + ...options.promptFails === undefined ? {} : { promptFails: options.promptFails }, + ...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt }, + ...options.idleInPrompt === true ? { onPrompt: emitIdle } : {}, + }) as never) ctx.provide('httpServer', { port: 12345 } as never) apply(ctx, { task: 'do the thing' }) // Quiescence is out of band: give the scripted stream a beat to drain, then // flip the agent idle exactly as the loop would. Foreign agents and // non-idle transitions must not settle the run. - await new Promise(resolve => setTimeout(resolve, 10)) - ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' }) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' }) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' }) + if (options.idleInPrompt !== true) { + await new Promise(resolve => setTimeout(resolve, 10)) + ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' }) + ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' }) + emitIdle() + } const code = await exited await ctx.fiber.dispose() return { code, out, err } @@ -106,6 +137,15 @@ describe('headless runner', () => { expect(err).toContain('observing at http://127.0.0.1:12345') }) + it('consumes through the idle sequence when queued frames arrive after the status transition', async () => { + const { code, out } = await run( + [messageTurn, text(1, 'race-free answer'), end(1, 'completed')], + { framesAfterPrompt: true, idleInPrompt: true }, + ) + expect(code).toBe(0) + expect(out).toBe('race-free answer\n') + }) + it('exits 1 when the final turn ends for any other reason', async () => { const { code } = await run([messageTurn, end(1, 'aborted')]) expect(code).toBe(1) @@ -168,7 +208,7 @@ describe('headless runner', () => { ctx.provide('httpServer', { port: 1 } as never) apply(ctx, { task: 't' }) await new Promise(resolve => setTimeout(resolve, 10)) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' }) + ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' }) expect(await exited).toBe(1) expect(err).toContain('event stream failed') await ctx.fiber.dispose() diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 961b48dd0b..627f624235 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 5506cbef7b778a870e1e28c3f9fdf1713f89d65f -README.zh.md: de31f653944097e9b47a966f56c418dc9fa9b1b9 +README.md: a3c9f214690144ec0f39a8690e4fd346f5e315e2 +README.zh.md: aeaf1b29e5a71674c9feedb30b67f9ce11c47340 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 5506cbef7b..a3c9f21469 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -52,7 +52,7 @@ The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-pag ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh run` headless. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index de31f65394..aeaf1b29e5 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -52,7 +52,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 载体层(`/client` + 根路径) -`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。 +`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh run` headless 模式使用。 ## 模型体验 From 21c380be52886cd2250878f49e4ff92cfa78f5f8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:08:31 +0800 Subject: [PATCH 051/100] docs(agent-notes): archive superseded dsh entrypoint decision --- .agents/notes/archived/manifest.json | 3 +++ .../2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml | 4 ++-- .../2026-08-03-explicit-config-dsh-entrypoint.md | 1 + .../2026-08-03-explicit-config-dsh-entrypoint.zh.md | 1 + .../simplification/2026-08-04-remove-tui-package.i18n.yaml | 4 ++-- .../simplification/2026-08-04-remove-tui-package.md | 2 +- .../simplification/2026-08-04-remove-tui-package.zh.md | 2 +- 7 files changed, 11 insertions(+), 6 deletions(-) rename .agents/notes/{implemented => archived}/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml (68%) rename .agents/notes/{implemented => archived}/simplification/2026-08-03-explicit-config-dsh-entrypoint.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md (99%) diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index c46bb59b44..1adde54c7b 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -376,6 +376,9 @@ "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml": "sha256:531c446f0e95054f8ced17be9a180f8b0a823f7e9d5ce466c94c2f9cff90a111", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md": "sha256:a35a6372aabdf7cbc211f1bd5820d85d3467c9ed50f84e05caa3339382379ce7", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md": "sha256:a6ed9530289a783c3d7a1ddb038fba6b7daf7feb773298a57e811791e354d438", + "simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml": "sha256:5466161f3fb8f2e8117fe8ff242675cc9fe9ef264d1e29b9bc586891c73c051a", + "simplification/2026-08-03-explicit-config-dsh-entrypoint.md": "sha256:f23accae7d05c2e75cb73ec69b492307f1ce7526ecfa9f6b12a621e02fd1a0c3", + "simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md": "sha256:a32d2c6ecf748a16a2c35b59cd2da2fda75769e3ab24be6a2e026d8655466db4", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml": "sha256:4177012c0821a8c22499852ecdf096af56d7263cb91c5d9d1bcd552cc26a3e00", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md": "sha256:45234e7cc04b6010c6141f8d5924c04547300098f96262d423c50108e7c7011a", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md": "sha256:15e5a4ad3dee0bb711480cabe45cd97ec37bbdba19c2c2b47d1e9c203b07a48b", diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml similarity index 68% rename from .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml rename to .agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml index ee43f9465c..b699495f93 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml +++ b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md -2026-08-03-explicit-config-dsh-entrypoint.md: e0d1e954d9cef472ea59345a3d2ef5a67bd03ae8 -2026-08-03-explicit-config-dsh-entrypoint.zh.md: 614c2d8600731d85c83d6559bc577350da25e872 +2026-08-03-explicit-config-dsh-entrypoint.md: 4474e786b3a99ff0ee81ac54fcb6eb5aaff5ee04 +2026-08-03-explicit-config-dsh-entrypoint.zh.md: 11b7b27c7560aaa43a9fdcfb12a9a17f9433e619 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md rename to .agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md index e0d1e954d9..4474e786b3 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md +++ b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md @@ -1,6 +1,7 @@ # Agent Note: Explicit-config dsh entrypoint Status: implemented +Archived: 2026-08-08 English | [中文](2026-08-03-explicit-config-dsh-entrypoint.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md rename to .agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md index 614c2d8600..11b7b27c75 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md +++ b/.agents/notes/archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md @@ -1,6 +1,7 @@ # Agent Note: 显式配置的 dsh 入口 Status: implemented +Archived: 2026-08-08 [English](2026-08-03-explicit-config-dsh-entrypoint.md) | 中文 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml index cd1133483c..e71be9bcf0 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md -2026-08-04-remove-tui-package.md: 7f7a0dd86ddd36e940ed8b7d6154185d9740341c -2026-08-04-remove-tui-package.zh.md: 36cb4b6a5e4eddd152eccee92c913bcca5b7fbae +2026-08-04-remove-tui-package.md: 1057243c70f6f2775a5d0c5f5eddcb72cbad699e +2026-08-04-remove-tui-package.zh.md: 0e03d6913aafaa3ce01c0f5732935d2c304c8e0e diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md index 7f7a0dd86d..1057243c70 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md @@ -16,7 +16,7 @@ The `packages/ui/tui` package is deleted without a compatibility package or alia The SDK run-interface union now contains only `acp` and `embed`. `create-sdk` defaults to ACP, generated templates contain no terminal startup, resume, session-environment, or model-argument branch, and the builtin `ask-user` feature is removed because neither remaining generated interface supplies a `UserInteractionProvider`. Host applications may still mount the provider-neutral `dsh-user-interaction`, `dsh-commands`, and presentation seams directly. -This decision supersedes the reusable-package retention in [the explicit-config `dsh` entrypoint decision](2026-08-03-explicit-config-dsh-entrypoint.md) and the current applicability of the archived TUI implementation notes. Their historical records remain frozen, but they are not authority for the supported package or application inventory. +This decision supersedes the reusable-package retention in [the explicit-config `dsh` entrypoint decision](../../archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md) and the current applicability of the archived TUI implementation notes. Their historical records remain frozen, but they are not authority for the supported package or application inventory. This note consolidates the deleted package-only records that could not remain current after removal. The terminal UI had kept session identity visible during long conversations, removed duplicate model labels, attached elapsed timing and phase status to messages, showed workspace and branch context beside the prompt, and conservatively parsed complete XML wrappers for human-readable fallback output. Those choices improved one terminal frontend but do not justify retaining it without a deployment. A future XML fallback must still use a real parser rather than regular expressions. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md index 36cb4b6a5e..0e03d6913a 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.zh.md @@ -16,7 +16,7 @@ Status: implemented SDK 的运行接口联合类型现在只包含 `acp` 与 `embed`。`create-sdk` 默认使用 ACP,生成的模板不再包含终端启动、恢复、会话环境或模型参数分支;内置的 `ask-user` 功能也被移除,因为剩余两个生成接口都不提供 `UserInteractionProvider`。宿主应用仍可直接挂载提供方无关的 `dsh-user-interaction`、`dsh-commands` 和呈现 seam。 -本决策取代[显式配置 `dsh` 入口决策](2026-08-03-explicit-config-dsh-entrypoint.md)中保留可复用包的决定,也使已归档 TUI 实现记录不再适用于当前状态。这些历史记录继续保持冻结,但不再作为受支持包或应用清单的依据。 +本决策取代[显式配置 `dsh` 入口决策](../../archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md)中保留可复用包的决定,也使已归档 TUI 实现记录不再适用于当前状态。这些历史记录继续保持冻结,但不再作为受支持包或应用清单的依据。 本记录汇总了删除后无法继续保持当前状态的仅限包记录。终端 UI 曾在长对话期间保持会话身份可见、移除重复模型标签、为消息附加耗时与阶段状态、在提示词旁显示 workspace 与分支上下文,并保守地解析完整 XML 包装层,以生成人类可读的回退输出。这些选择改善了一个终端前端,但没有部署时不足以证明应保留它。未来的 XML 回退仍必须使用真实解析器而非正则表达式。 From 33a7b1284e490e6445f5212d016070613a85be07 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:20:11 +0800 Subject: [PATCH 052/100] test(snapshot): keep dsh run plugin metadata static --- .../tests/fixtures/dsh-run.cordis.yml | 2 +- .../headless-agent/tests/headless.snapshot.ts | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml index e67630c029..7d411f7a0f 100644 --- a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml +++ b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml @@ -5,4 +5,4 @@ - insert: - id: cli-mock-llm - name: !!js process.env.DSH_RUN_MOCK_PLUGIN_URL + name: './snapshot-fixtures/cli-mock-llm.ts' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 9145c52f5a..e8561b0196 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -1,8 +1,8 @@ -import { readFile, readdir, writeFile } from 'node:fs/promises' +import { copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { delimiter, dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { fileURLToPath } from 'node:url' import { normalizeSessionLog, normalizeStdout, @@ -54,9 +54,7 @@ const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', i const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) const dshRunOverlayPath = fileURLToPath(new URL('./fixtures/dsh-run.cordis.yml', import.meta.url)) const dshRunSessionExpected = join(snapshotsDir, 'dsh-run', 'session.expected.jsonl') -const cliMockLlmPluginUrl = pathToFileURL( - fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)), -).href +const cliMockLlmPluginPath = fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -209,11 +207,18 @@ describe('headless stream-json snapshots', () => { binArgs: ['run', '--patch', dshRunOverlayPath, task], tsconfigPath, env: { - DSH_RUN_MOCK_PLUGIN_URL: cliMockLlmPluginUrl, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, + prepare: async (cwd) => { + const fixtureDir = join(cwd, '.dsh', 'profiles', 'headless', 'snapshot-fixtures') + await mkdir(fixtureDir, { recursive: true }) + await Promise.all([ + copyFile(cliMockLlmPluginPath, join(fixtureDir, 'cli-mock-llm.ts')), + writeFile(join(fixtureDir, 'package.json'), '{"type":"module"}\n'), + ]) + }, inspect: async (cwd) => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) From e8ee305b7b68063a9246b8eaf3554d8e0a0052fe Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:30:52 +0800 Subject: [PATCH 053/100] test(snapshot): refresh dsh run translation prompt fixture --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 0748e762dd..f0a9390b78 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", From a8879430f0407bc12b120d9bc5efcff400ab6175 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:20:06 +0800 Subject: [PATCH 054/100] ci: harden private repository link gate --- .../verify-public-repository-links.spec.ts | 21 ++++++++++++---- scripts/verify-public-repository-links.ts | 24 ++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/scripts/verify-public-repository-links.spec.ts b/scripts/verify-public-repository-links.spec.ts index 615bfa68e2..ec12f38427 100644 --- a/scripts/verify-public-repository-links.spec.ts +++ b/scripts/verify-public-repository-links.spec.ts @@ -2,18 +2,31 @@ import { describe, expect, it } from 'vitest' import { findInternalRepositoryReferences } from './verify-public-repository-links.ts' describe('public repository link policy', () => { - it('rejects internal repository references and accepts the public home', () => { + it('rejects encoded and case-varied internal identities without blocking public repositories', () => { const internalOwner = ['deepseek', 'harness'].join('-') const internalRepository = [internalOwner, internalOwner].join('/') + const encodedRepository = internalRepository.replaceAll('-', '%2D').replace('/', '%2F') + const htmlEncodedRepository = internalRepository.replace('/', '/') + const jsonEscapedRepository = internalRepository.replace('/', '\\/') + const unicodeEscapedRepository = internalRepository.replace('/', String.raw`\u002f`) const source = [ 'https://github.com/deepseek-ai/deepseek-harness-sdk', - `https://github.com/${internalRepository}/issues/1`, - `${internalOwner}#2`, + `https://github.com/${internalOwner}/cordis`, + `https://github.com/${internalRepository.toUpperCase()}/issues/1`, + `https://github.com/${encodedRepository}/issues/2`, + `https://github.com/${htmlEncodedRepository}/issues/3`, + `"https:\\/\\/github.com\\/${jsonEscapedRepository}\\/issues\\/4"`, + `"https:\\/\\/github.com\\/${unicodeEscapedRepository}\\/issues\\/5"`, + `${internalOwner.toUpperCase()}#6`, ].join('\n') expect(findInternalRepositoryReferences('subject.md', source)).toEqual([ - { file: 'subject.md', line: 2 }, { file: 'subject.md', line: 3 }, + { file: 'subject.md', line: 4 }, + { file: 'subject.md', line: 5 }, + { file: 'subject.md', line: 6 }, + { file: 'subject.md', line: 7 }, + { file: 'subject.md', line: 8 }, ]) }) }) diff --git a/scripts/verify-public-repository-links.ts b/scripts/verify-public-repository-links.ts index a57628e00c..6d1e537733 100644 --- a/scripts/verify-public-repository-links.ts +++ b/scripts/verify-public-repository-links.ts @@ -10,6 +10,27 @@ const internalOwner = ['deepseek', 'harness'].join('-') const internalRepository = [internalOwner, internalOwner].join('/') const internalIssueShorthand = `${internalOwner}#` +const namedReferenceCharacters: Readonly> = { + hyphen: '-', + num: '#', + sol: '/', +} + +/** Normalize source spellings that render or decode to repository separators. */ +function canonicalReferenceText(source: string): string { + return source + .replaceAll('\\/', '/') + .replace(/\\u(0023|002d|002f)/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/%(23|2d|2f)/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/&#(?:(\d+)|x([\da-f]+));/gi, (entity, decimal: string | undefined, hexadecimal: string | undefined) => { + const code = Number.parseInt(decimal ?? hexadecimal ?? '', decimal === undefined ? 16 : 10) + return code === 35 || code === 45 || code === 47 ? String.fromCodePoint(code) : entity + }) + .replace(/&(hyphen|num|sol);/gi, (entity, name: string) => namedReferenceCharacters[name.toLowerCase()] ?? entity) + .normalize('NFKC') + .toLowerCase() +} + /** One tracked reference to the internal repository. */ export interface InternalRepositoryReference { /** Repository-relative file path. */ @@ -27,7 +48,8 @@ export interface InternalRepositoryReference { export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] { const references: InternalRepositoryReference[] = [] for (const [index, line] of source.split('\n').entries()) { - if (line.includes(internalRepository) || line.includes(internalIssueShorthand)) { + const canonicalLine = canonicalReferenceText(line) + if (canonicalLine.includes(internalRepository) || canonicalLine.includes(internalIssueShorthand)) { references.push({ file, line: index + 1 }) } } From 63f88997bb796a492b02a322733c2e0c87fc0d5b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 14:36:03 +0800 Subject: [PATCH 055/100] fix(web): preserve compact icon until hover --- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/CompactionItem.tsx | 13 ++++++-- .../src/client/chat/MessageItem.module.css | 33 +++++++++++++++---- .../ui-conversation/tests/chat-view.spec.tsx | 3 ++ 6 files changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ca3289ba55..d29623d0f2 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 5b37065097ef60c2edf14725f4e1e1c6a52c4366 -README.zh.md: 4ec26155a124497db0fc7f351d20ecb451a18763 +README.md: 985c78e97a8c7f46451252e67095c91e990aa694 +README.zh.md: 36a2f7a13c3f60692f9547ccf5a51ad6356d26d8 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5b37065097..985c78e97a 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key, showing the replaced-item and estimated-token counts and disclosing the summary on click. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. +Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key, showing the replaced-item and estimated-token counts and disclosing the summary on click. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4ec26155a1..36a2f7a13c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行,显示被替换条目数量和估算 token 数量,并可点击展开摘要。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 +压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行,显示被替换条目数量和估算 token 数量,并可点击展开摘要。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx index 7049688cc0..5e5f0c87b7 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -9,6 +9,7 @@ import { memo, useState } from 'react' import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client' import { + IconApiOutline14, IconChevronDownOutline14, IconChevronRightOutline14, MarkdownText, @@ -56,8 +57,16 @@ export const CompactionItem = memo(function CompactionItem({ aria-expanded={expandable ? open : undefined} onClick={() => { setExpanded(value => !value) }} > - - {open ? : } + + + + + + {open ? : } + {title ?? t('message.compaction')} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 5c07ace71e..c6ca35bb2c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -33,9 +33,9 @@ padding: 2px 0; } -/* Compaction marker: one dim 24px row with a chevron disclosure for the - summary body. Dimmed title (not label-primary) — the row is a boundary - notice, not conversation content. */ +/* Compaction marker: one dim 24px row with a context icon at rest and a + hover/focus disclosure for the summary body. Dimmed title (not + label-primary) — the row is a boundary notice, not conversation content. */ .compactionRow { padding: 2px 0; } @@ -65,15 +65,36 @@ .compactionLeading { flex: none; - display: inline-flex; - align-items: center; - justify-content: center; + display: inline-grid; + place-items: center; width: 16px; height: 16px; margin-right: 6px; color: var(--dsw-alias-label-secondary); } +.compactionContextIcon, +.compactionDisclosureIcon { + display: inline-flex; + grid-area: 1 / 1; + align-items: center; + justify-content: center; +} + +.compactionDisclosureIcon { + opacity: 0; +} + +.compactionButton:not(:disabled):hover .compactionContextIcon, +.compactionButton:not(:disabled):focus-visible .compactionContextIcon { + opacity: 0; +} + +.compactionButton:not(:disabled):hover .compactionDisclosureIcon, +.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon { + opacity: 1; +} + .compactionTitle { flex: none; font-size: 14px; diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index a0213a2407..b16a0b9317 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -1303,9 +1303,12 @@ describe('ChatView', () => { expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() const row = view.getByRole('button', { name: /compact/ }) expect(row.getAttribute('aria-expanded')).toBe('false') + expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull() + expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull() expect(view.queryByText('保留的事实。')).toBeNull() fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') + expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull() expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy() }) From 04e6a9806450426203a71e316a3398ee0f358ddd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 14:42:07 +0800 Subject: [PATCH 056/100] feat(client): surface subagent activity in sidebar --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 6 +- ...026-07-27-web-subagent-conversations.zh.md | 6 +- .../sidebar-running.expected.md | 5 ++ apps/web/tests/subagent-conversation.e2e.ts | 10 +++ packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + packages/client/runtime/src/client/index.ts | 2 + .../src/client/sessions/subagent-lineage.ts | 50 +++++++++++ .../runtime/tests/subagent-lineage.spec.ts | 53 ++++++++++++ .../src/client/SubagentCatalogAction.tsx | 46 +++------- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../client/ui-workspace/src/client/locales.ts | 4 + .../ui-workspace/src/client/rows/Rows.tsx | 86 +++++++++++++------ .../client/ui-workspace/src/client/tree.ts | 25 ++++-- .../client/ui-workspace/tests/rows.spec.tsx | 72 +++++++++++++--- .../client/ui-workspace/tests/tree.spec.ts | 28 +++++- 20 files changed, 320 insertions(+), 93 deletions(-) create mode 100644 apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md create mode 100644 packages/client/runtime/src/client/sessions/subagent-lineage.ts create mode 100644 packages/client/runtime/tests/subagent-lineage.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index abb388a410..ac7fc12150 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 03c805d30dbdbbdb33d0b06ba8036ec181035ac6 -2026-07-27-web-subagent-conversations.zh.md: e83f07fb21f58ad175ab3e5638981648fa4cef05 +2026-07-27-web-subagent-conversations.md: 60717365244ff3ca3ebc4a400b15bfc81062212c +2026-07-27-web-subagent-conversations.zh.md: 217ed44249c0d1beb731e24e52054f200fae4b9b diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 03c805d30d..6071736524 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -37,7 +37,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha ## Product contract -The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies its blue activity indicator and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Parent running and pending interaction remain distinct primary statuses; descendant activity becomes a second hover and assistive status when either is present. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. @@ -104,8 +104,8 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, sidebar propagation across nested lineage and ordinary-fork boundaries, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, aggregate running transition in the header and owner sidebar row, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index e83f07fb21..217ed44249 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -37,7 +37,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 ## 产品契约 -只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。parent 的运行中状态与待处理交互仍是彼此不同的主要状态;只要其中任一存在,后代活动就成为悬停与无障碍状态中的第二项。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 @@ -104,8 +104,8 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、侧边栏活动在嵌套谱系中的传播与普通 fork 边界、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及页头和 owner 侧边栏行中的聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 diff --git a/apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md b/apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md new file mode 100644 index 0000000000..fec6fe658e --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/sidebar-running.expected.md @@ -0,0 +1,5 @@ +- tree "Sessions": + - treeitem "workspace 1 session" [expanded]: + - img + - text: workspace 1 session + - treeitem "1 subagent running Ask a research subagent to now" [selected] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 0049cb2791..69250ed6b6 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -23,6 +23,7 @@ const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/t const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) +const RUNNING_SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar-running.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url)) const MODE = webSnapshotMode() @@ -367,6 +368,15 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ).toBe('running') const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) await hierarchy.getByRole('button').first().click() + const runningOwnerRow = page.getByRole('tree', { name: 'Sessions' }) + .getByRole('treeitem', { name: /1 subagent running/ }) + await runningOwnerRow.waitFor({ timeout: 10_000 }) + expect(await runningOwnerRow.locator('[data-state="ongoing"]').count()).toBe(1) + await compareOrRefreshGolden( + RUNNING_SIDEBAR_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) const runningTrigger = page.getByRole('button', { name: '3 subagents running' }) await runningTrigger.waitFor({ timeout: 10_000 }) expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 23c867e4c0..8556e393b6 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27 -README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d +README.md: 00427f33b1dfc23b157c8fe4cfefb42cf313ee66 +README.zh.md: 0a27602a4408792e7f02ecd3995aeb5946e81aab diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 8ac29a4258..00427f33b1 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -22,6 +22,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. +`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. + `SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. ## New Session and the blank mirror diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 0e065e43ec..0a27602a44 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -22,6 +22,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 +`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 + `SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 ## New Session 与 blank 镜像 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index e4f8e57b04..ceee6a1f10 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -15,6 +15,8 @@ export { SlotsService } from './slots.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { SessionHistoryService } from './session-history/service.ts' +export { indexSubagentDescendants } from './sessions/subagent-lineage.ts' +export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts' // The provide channel is shared with the client test runtime (one // materialization/projection implementation; no test-side mirror to drift). export { SessionProvideChannel } from './sessions/provide.ts' diff --git a/packages/client/runtime/src/client/sessions/subagent-lineage.ts b/packages/client/runtime/src/client/sessions/subagent-lineage.ts new file mode 100644 index 0000000000..fb56b4eb8c --- /dev/null +++ b/packages/client/runtime/src/client/sessions/subagent-lineage.ts @@ -0,0 +1,50 @@ +/** + * Pure subagent-lineage aggregation over the retained session-list mirror. + * Ordinary forks terminate propagation so each visible session owns only its + * uninterrupted subagent subtree. + * @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage + */ +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionSummary } from './service.ts' + +/** Descendant counts projected for one possible parent session. */ +export interface SubagentDescendantSummary { + /** All descendants connected through uninterrupted subagent-origin lineage. */ + readonly count: number + /** Descendants whose exact session summary is currently running. */ + readonly runningCount: number +} + +/** + * Index every subagent descendant under each ancestor it reaches through an + * uninterrupted subagent-origin chain. Cycles fail soft and orphan owners + * remain harmless map keys until their summaries arrive. + * @param summaries - retained session summaries keyed by id. + * @returns descendant totals and running totals keyed by possible parent id. + */ +export function indexSubagentDescendants( + summaries: Readonly>, +): ReadonlyMap { + const indexed = new Map() + for (const descendant of Object.values(summaries)) { + if (descendant.origin !== 'subagent') continue + const seen = new Set() + let current: SessionSummary | undefined = descendant + while (current?.origin === 'subagent' && current.parentId !== undefined + && !seen.has(current.id)) { + seen.add(current.id) + const aggregate = indexed.get(current.parentId) + if (aggregate === undefined) { + indexed.set(current.parentId, { + count: 1, + runningCount: descendant.running ? 1 : 0, + }) + } else { + aggregate.count += 1 + if (descendant.running) aggregate.runningCount += 1 + } + current = summaries[current.parentId] + } + } + return indexed +} diff --git a/packages/client/runtime/tests/subagent-lineage.spec.ts b/packages/client/runtime/tests/subagent-lineage.spec.ts new file mode 100644 index 0000000000..05881576bf --- /dev/null +++ b/packages/client/runtime/tests/subagent-lineage.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +import { indexSubagentDescendants } from '@deepseek-ai/dsh-client-runtime/client' + +const sid = (id: string) => id as SessionId + +function summary( + id: string, + parentId?: SessionId, + origin?: 'subagent', + running = false, +): SessionSummary { + return { + id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0, + ...(parentId === undefined ? {} : { parentId }), + ...(origin === undefined ? {} : { origin }), + } +} + +function index(...summaries: SessionSummary[]) { + return indexSubagentDescendants(Object.fromEntries( + summaries.map(item => [item.id, item]), + )) +} + +describe('indexSubagentDescendants', () => { + it('counts every nested descendant and its exact running state', () => { + const owner = summary('owner') + const child = summary('child', owner.id, 'subagent') + const grandchild = summary('grandchild', child.id, 'subagent', true) + + const result = index(owner, child, grandchild) + expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 }) + expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 }) + }) + + it('stops at ordinary forks and fails soft on cycles and missing parents', () => { + const owner = summary('owner') + const child = summary('child', owner.id, 'subagent', true) + const fork = summary('fork', child.id) + const forkChild = summary('fork-child', fork.id, 'subagent', true) + const orphan = summary('orphan', sid('missing'), 'subagent', true) + const cycleA = summary('cycle-a', sid('cycle-b'), 'subagent') + const cycleB = summary('cycle-b', sid('cycle-a'), 'subagent') + + const result = index(owner, child, fork, forkChild, orphan, cycleA, cycleB) + expect(result.get(owner.id)).toEqual({ count: 1, runningCount: 1 }) + expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 }) + expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 }) + expect(result.get(cycleA.id)).toEqual({ count: 2, runningCount: 0 }) + expect(result.get(cycleB.id)).toEqual({ count: 2, runningCount: 0 }) + }) +}) diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 76a216ebd8..50850f0b98 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -1,9 +1,9 @@ import { - useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, + useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, } from 'react' -import type { - SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress, - SubagentCatalogSnapshot, +import { + indexSubagentDescendants, type SessionId, type SessionListState, type SessionProjectionMap, + type SessionSummary, type SubagentAddress, type SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, @@ -171,30 +171,7 @@ function formatExactDuration(ms: number, t: TranslateNS): string { }) } -/** Aggregate the complete subagent-only descendant subtree from flat summaries. */ -function summarizeDescendants( - sessionId: SessionId, - summaries: Readonly>, -): { count: number; running: boolean } { - let count = 0 - let running = false - for (const summary of Object.values(summaries)) { - if (summary.origin !== 'subagent') continue - const seen = new Set() - let current: SessionSummary | undefined = summary - while (current?.origin === 'subagent' && current.parentId !== undefined - && !seen.has(current.id)) { - seen.add(current.id) - if (current.parentId === sessionId) { - count += 1 - running ||= summary.running - break - } - current = summaries[current.parentId] - } - } - return { count, running } -} +const NO_DESCENDANTS = { count: 0, runningCount: 0 } as const /** Render the known direct-child shape while its authoritative catalog hydrates. */ function CatalogLoadingRows({ @@ -448,7 +425,10 @@ export function SubagentCatalogAction({ const setCatalogOpenRef = useRef(setCatalogOpen) setCatalogOpenRef.current = setCatalogOpen const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? [] - const descendants = summarizeDescendants(sessionId, summaries) + const descendants = useMemo( + () => indexSubagentDescendants(summaries).get(sessionId) ?? NO_DESCENDANTS, + [sessionId, summaries], + ) // The catalog can arrive before the session-list baseline; never undercount // the already-visible direct rows during that short bootstrap window. const descendantCount = Math.max(healthy.length, descendants.count) @@ -527,10 +507,10 @@ export function SubagentCatalogAction({ }, [open]) useEffect(() => { - if (!open || !descendants.running) return + if (!open || descendants.runningCount === 0) return const timer = setInterval(() => { setNow(Date.now()) }, 1_000) return () => { clearInterval(timer) } - }, [open, descendants.running]) + }, [open, descendants.runningCount]) useEffect(() => () => { for (const parentSessionId of observedCatalogs.current) { @@ -584,7 +564,7 @@ export function SubagentCatalogAction({ className={css.trigger} aria-haspopup="tree" aria-expanded={open} - aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })} + aria-label={t(descendants.runningCount > 0 ? runningCountKey : totalCountKey, { count: descendantCount })} onClick={() => { changeOpen(!open) }} onKeyDown={(event) => { if (event.key !== 'ArrowDown') return @@ -594,7 +574,7 @@ export function SubagentCatalogAction({ }} > - {descendants.running && } + {descendants.runningCount > 0 && } {t(totalCountKey, { count: descendantCount })} diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index fee956b683..c5cfc89ea5 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: bd7313b560e76378e4fff274c99bb976819aebae -README.zh.md: 734a897b9cb9c3469d8f402b13bff4b62753f9b2 +README.md: b2baea049c30f46c3194009e71c70b38973dc526 +README.zh.md: cc50ba3691db10a533337b0e99689fba72a679ca diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index bd7313b560..b2baea049c 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -16,7 +16,7 @@ Session rows render the runtime's live `pendingInteraction` classification: appr Both target slots are declared by other plugins, so `apply` uses `slots.inject()` to register for each declaration lifetime and re-register after a declaring slot is restored. -The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state. +The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Each visible ordinary row inherits the blue activity indicator while any descendant reached through uninterrupted subagent-origin lineage is running, and its hover and assistive text report the exact running-descendant count without describing an idle parent as running. Ordinary forks remain visible and terminate this aggregation because lineage alone does not set their origin. Pending user interaction remains the primary row marker while descendant activity stays available as a separate hover and assistive status. The runtime keeps hidden rows available for conversation, title, and addressed transport state. ## Model Experience diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 734a897b9c..cc50ba3691 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -16,7 +16,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** 两个目标 slot 都由其他插件声明,因此 `apply` 使用 `slots.inject()` 在各自的声明生命周期内完成注册,并在目标 slot 的声明恢复后重新注册。 -共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 +共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。每个可见的普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器;其悬停与无障碍文本会报告确切的运行中后代数量,同时不会把空闲 parent 描述为正在运行。普通 fork 仍然可见,并会终止此聚合,因为仅有谱系不会设置该 origin。待处理的用户交互仍是主要行标记,而后代活动会作为独立的悬停与无障碍状态保留。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 ## 模型体验 diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index d9c70de729..30fe6bfcc0 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -45,6 +45,8 @@ export const zh = { 'actions.session.aria': '会话“{name}”的操作', 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', + 'status.subagentsRunning.one': '{n} 个子代理运行中', + 'status.subagentsRunning.other': '{n} 个子代理运行中', 'status.idle': '空闲', 'status.waitingApproval': '等待审批', 'status.planReview': '计划待审', @@ -106,6 +108,8 @@ export const en = { 'actions.session.aria': 'Session actions for {name}', 'actions.newSession.aria': 'New session in {name}', 'status.running': 'Running', + 'status.subagentsRunning.one': '{n} subagent running', + 'status.subagentsRunning.other': '{n} subagents running', 'status.idle': 'Idle', 'status.waitingApproval': 'Waiting for approval', 'status.planReview': 'Plan awaiting review', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index fb64a0be42..3d3b40403d 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -171,37 +171,67 @@ function assertNever(value: never): never { throw new Error(`unknown pending interaction: ${String(value)}`) } -/** Session status presentation; pending user interaction outranks the running state. */ -function sessionStatus( - node: Pick, +interface SessionStatus { + state: StateDotState + label: string +} + +/** Session status presentation; pending user interaction remains primary. */ +function sessionStatuses( + node: Pick, t: RowTranslate, -): { state: StateDotState; label: string } { +): readonly [SessionStatus, ...SessionStatus[]] { + const subagents: SessionStatus | undefined = node.runningSubagentCount === 0 + ? undefined + : { + state: 'ongoing', + label: t( + node.runningSubagentCount === 1 + ? 'status.subagentsRunning.one' + : 'status.subagentsRunning.other', + { n: node.runningSubagentCount }, + ), + } + let pending: SessionStatus | undefined switch (node.pendingInteraction) { - case 'approval': return { state: 'warning', label: t('status.waitingApproval') } - case 'plan-review': return { state: 'warning', label: t('status.planReview') } - case 'question': return { state: 'warning', label: t('status.waitingAnswer') } + case 'approval': + pending = { state: 'warning', label: t('status.waitingApproval') } + break + case 'plan-review': + pending = { state: 'warning', label: t('status.planReview') } + break + case 'question': + pending = { state: 'warning', label: t('status.waitingAnswer') } + break case undefined: break /* v8 ignore next -- closed PendingInteractionStatus union */ default: return assertNever(node.pendingInteraction) } - if (node.running) return { state: 'ongoing', label: t('status.running') } - if (node.completed) return { state: 'done', label: t('status.completed') } - return { state: 'done', label: t('status.idle') } + if (pending !== undefined) return subagents === undefined ? [pending] : [pending, subagents] + if (node.running) { + const primary: SessionStatus = { state: 'ongoing', label: t('status.running') } + return subagents === undefined ? [primary] : [primary, subagents] + } + if (subagents !== undefined) return [subagents] + if (node.completed) return [{ state: 'done', label: t('status.completed') }] + return [{ state: 'done', label: t('status.idle') }] } -/** Hover-card body: full title, relative time, and interaction/running/completed/idle status. */ +/** Hover-card body: full title, relative time, and every relevant live status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { - const status = sessionStatus(node, t) + const statuses = sessionStatuses(node, t) return (
{displayTitle(node, t)}
{/* Same placeholder rule as the row's trailing cell: no timestamp before the first prompt. */} {!node.blank &&
{hoverTimeLabel(node.updatedAt, now, t)}
} -
- - {status.label} -
+ {statuses.map(status => ( +
+ + {status.label} +
+ ))}
) } @@ -241,7 +271,8 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { t: RowTranslate }) { const selected = result.id === currentId - const status = sessionStatus(result, t) + const statuses = sessionStatuses(result, t) + const primaryStatus = statuses[0] return (
) } - -/** - * The Output section's body for the selected call. A terminal-card call — a - * shell command's call/result views — renders through the shared TerminalBlock - * at the primitive's own full height allowance, so column-aligned output keeps - * its alignment and scrolls sideways instead of folding. A read-card call - * renders through the shared ReadBlock at that same full height, so the whole - * returned window is line-numbered and highlighted. A diff-card call — a - * write/edit's applied change — renders through the shared DiffBlock at the same - * full height. A search-card call — a `grep`/`glob` result view — renders - * through the shared SearchBlock at the same full height allowance, with a - * capped search's recovery footer below it. A web-card call — a - * `web_search`/`web_fetch` result — renders through WebBlock at its own full - * source-list allowance. Every other call, and a running call with no card yet, - * keeps the flattened text form. - * @param props.material - the selected call's material from {@link materialFor}. - * @param props.cwd - the session workspace root, resolving the terminal view's cwd. - * @param props.t - the panel's locale seat, passed down as a plain prop. - * @returns the Output section's body element. - */ -function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string | undefined; t: DetailsPanelProps['t'] }) { - const terminal = terminalCardModel(material.block, cwd) - if (terminal !== null) { - // The contract renders the presenter's description above the card, and the - // panel has no summary row to carry it, so it is drawn here. - return ( - <> - {terminal.description !== undefined && ( -
{terminal.description}
- )} - - - ) - } - const read = readCardModel(material.block, cwd) - // The panel takes the primitive's own default cap, not the row's tighter one: - // it is the single-call reading surface, so the whole window is available. - if (read !== null) return - const diff = diffCardModel(material.block) - if (diff !== null) return - const search = searchCardModel(material.block) - if (search !== null) { - return ( - <> - - {/* A capped search's recovery locator lives only in the result text; - show it below the card so the dropped rows stay reachable. */} - {search.recovery !== undefined && ( -
{search.recovery}
- )} - - ) - } - const web = webCardModel(material.block) - // The card shows every source the tool returned (the same list the model saw), - // scrolling within its own capped height. Below the card the panel also renders - // the flattened result content — the model-visible text the card does not carry - // verbatim (a web_fetch card shows only the URL and status, so its fetched body - // lives only here; a search card's answer and sources are structured, so the - // flattened form repeats them as the raw text the model saw). - if (web !== null) { - const settled = 'kind' in material.block ? material.block : null - const body = settled === null ? '' : resultText(settled) - return ( - <> - - {body !== '' &&
{body}
} - - ) - } - // A settled call always carries the result node the flattened form needs; - // the running shape has no result to flatten. - if (!('kind' in material.block)) return
{t('details.running')}
- const result = material.block - return ( -
-      {resultText(result)}
-    
- ) -} diff --git a/packages/client/ui-conversation/src/invariant.ts b/packages/client/ui-conversation/src/invariant.ts index f4ecd7e260..f9a7d46553 100644 --- a/packages/client/ui-conversation/src/invariant.ts +++ b/packages/client/ui-conversation/src/invariant.ts @@ -17,7 +17,7 @@ export const inject = ['invariants'] /** * No runtime invariant: the conversation service emits no cordis events, and * both rings this package owns (the 'conversation.view' tab ring and the - * 'conversation.chat.toolview' row hole) ride the slot system, whose ledger + * 'conversation.chat.tool' whole-call seat) ride the slot system, whose ledger * invariants live with the runtime slots package. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 16163065eb..9ea1c937ff 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -1,35 +1,14 @@ // @vitest-environment jsdom -/** - * Assembly-level acceptance on SlotTestRuntime (real apply, real slot - * machinery, real renderer; data fed as fixtures) for surfaces that were - * previously pinned only by the assembled-app jsdom snapshots - * (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts): - * - * - the todo_write turn reaches BOTH surfaces through the product - * registrations (keyed toolview row in the flow, plan strip in the input - * dock via the 'todos' projection) and the strip follows projection - * retirement; - * - the bash keyed row carries its resident terminal card, and the fallback - * row reaches the same card through its expand control; - * - the resident composer textarea survives the blank→active conversion as - * the SAME DOM node (focus/IME continuity rides React reconciliation: - * component identity + tree position, which this assembled tree pins). - * - * Component-level behavior (collapse interaction, card model arms, summary - * derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this - * suite only proves the assembled wiring. - */ +/** Conversation assembly acceptance independent of Tool presentation. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' import { useState } from 'react' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') const SID = 's1' as SessionId @@ -50,30 +29,6 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) -const TODOS: TodoItem[] = [ - { content: '梳理需求', status: 'completed' }, - { content: '实现 fixture 样本', status: 'in_progress' }, - { content: '浏览器验收', status: 'pending' }, -] - -const todoResult = (seq: number): ToolResultNode => ({ - kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`, - call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) }, - callTime: seq * 1_000 - 500, - content: [], isError: false, callView: null, resultView: null, -}) - -const bashResult = (seq: number, callId: string, over?: Partial): ToolResultNode => ({ - kind: 'tool-result', seq, time: seq * 1_000, callId, - call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' }, - callTime: seq * 1_000 - 500, - content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false, - callView: { card: 'terminal', title: 'ls -la', description: 'List files' }, - resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 }, - ...over, -}) - -/** Test-owned AppFrame role: declares and renders the resident conversation area. */ type AppRootProps = PropsRenderSlots<'conversation' | 'details'> function AppRoot({ renderSlot }: AppRootProps) { return <>{renderSlot('conversation', {})} @@ -84,7 +39,6 @@ const LAYOUT_CHILDREN = { 'details': { kind: 'single', scope: 'session' }, } as const -/** Stateful occupant proving the root-scoped Hero workspace outlet is not rebuilt. */ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { const [count, setCount] = useState(0) return ( @@ -94,7 +48,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { ) } -async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) { +async function bench(opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) @@ -104,7 +58,7 @@ async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) { id: SID, summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, snapshot: { - nodes, + nodes: [], ...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}), }, session: { @@ -117,69 +71,6 @@ async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) { return runtime } -describe('todo_write assembly (product registrations, no outlet twins)', () => { - it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => { - const runtime = await bench([todoResult(3)]) - // The dock strip reads the host-computed 'todos' projection. - runtime.sessions.behavior(SID).projections.set('todos', TODOS) - const view = runtime.renderRoot() - - // Keyed toolview registration took the row (summary derived from args). - const row = view.container.querySelector('[data-tool="todo_write"]') - expect(row).not.toBeNull() - expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本') - - // The plan strip sits in the input dock, fed by the projection - // (default-collapsed: the header summary shows; rows appear on expand). - const panel = view.container.querySelector('[data-testid="todo-panel"]') - expect(panel).not.toBeNull() - expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理') - fireEvent.click(panel!.querySelector('button')!) - expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status'))) - .toEqual(['completed', 'in_progress', 'pending']) - - // Next turn retires the standing plan (host pushes null): the strip - // clears while the historical row stays in the flow. - await runtime.flush() - runtime.sessions.behavior(SID).projections.set('todos', null) - await waitFor(() => { - expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull() - }) - expect(view.container.querySelector('[data-tool="todo_write"]')).not.toBeNull() - await runtime.dispose() - }) -}) - -describe('terminal card assembly', () => { - it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => { - const runtime = await bench([ - bashResult(3, 'c-keyed'), - // An unregistered tool with terminal views: GenericToolCard fallback. - bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }), - ]) - const view = runtime.renderRoot() - - // Keyed BashRow: collapsed by default, the whole summary row is the toggle. - const keyedRow = view.container.querySelector('[data-sample="bash"]') - const keyed = keyedRow?.parentElement - expect(keyed?.querySelector('[data-terminal]')).toBeNull() - fireEvent.click(keyedRow!) - await waitFor(() => { - expect(keyed!.querySelector('[data-terminal]')).not.toBeNull() - }) - - // Fallback row: same unified expand interaction. - const fallback = view.container.querySelector('[data-tool="fx-bash"]') - expect(fallback).not.toBeNull() - expect(fallback!.querySelector('[data-terminal]')).toBeNull() - fireEvent.click(fallback!.querySelector('[data-expandable]')!) - await waitFor(() => { - expect(fallback!.querySelector('[data-terminal]')).not.toBeNull() - }) - await runtime.dispose() - }) -}) - describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() @@ -190,8 +81,6 @@ describe('resident composer', () => { await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...inject], apply }) const view = runtime.renderRoot() - // No session entity: the inert twin renders (disabled textarea), and the - // workspace picker chip is the only live control. const textarea = view.container.querySelector('textarea') expect(textarea).not.toBeNull() expect(textarea!.disabled).toBe(true) @@ -242,12 +131,8 @@ describe('resident composer', () => { await runtime.dispose() }) - it('the textarea survives the blank→active conversion as the same DOM node', async () => { - const runtime = await bench([], { blank: true }) - // The hero renders the LIVE composer only when the blank session's - // workspace resolves a chip title; an ownerless blank session shows the - // disabled twin instead (deleted-workspace semantics). + const runtime = await bench({ blank: true }) await runtime.workspaces.update((draft) => { draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never }) @@ -256,13 +141,11 @@ describe('resident composer', () => { expect(hero).not.toBeNull() expect(hero!.disabled).toBe(false) - // First acceptance: the session leaves blank and the composer docks. await runtime.sessions.updateSnapshot(SID, (draft) => { draft.blank = false draft.composerPhase = 'active' }) - const docked = view.container.querySelector('textarea') - expect(docked).toBe(hero) + expect(view.container.querySelector('textarea')).toBe(hero) await runtime.dispose() }) }) @@ -291,8 +174,6 @@ describe('prompt rejection through the assembled composer', () => { fireEvent.keyDown(composer, { key: 'Enter' }) await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() }) - // The rejection lands in snapshot.promptError (the Session's own path); - // the fixture mirrors that hop — the assembled InputBar renders it. await runtime.sessions.updateSnapshot(SID, (draft) => { draft.promptError = { op: 'send', @@ -301,7 +182,6 @@ describe('prompt rejection through the assembled composer', () => { }) const alert = await view.findByRole('alert') expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)') - // Failure restore: the machine returned the draft to the same textarea. await waitFor(() => { expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this') }) @@ -311,7 +191,7 @@ describe('prompt rejection through the assembled composer', () => { describe('title projection across assembled surfaces', () => { it('one summary update re-labels the current-session crumb', async () => { - const runtime = await bench([]) + const runtime = await bench() const view = runtime.renderRoot() const hierarchy = view.getByRole('navigation', { name: '会话层级' }) expect(within(hierarchy).getByRole('button', { name: 'S' }).hasAttribute('disabled')).toBe(true) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index df8fff6719..00001275d2 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -1,12 +1,9 @@ // @vitest-environment jsdom // apply wiring: the conversation service provided, the chat view registered -// as the first 'conversation.view' ring entry declaring the keyed toolview -// hole, the slot registrations land against a root entry's children -// declarations (the AppFrame role), the shared store handle rides all strict -// session entries, and the bash sample + todo row mount through declaration -// injection as keyed entries. Full-chain rendering belongs to the -// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec -// stops at the assembly surface. +// as the first 'conversation.view' ring entry declaring the whole-Tool seat, +// the slot registrations land against a root entry's children declarations +// (the AppFrame role), and the shared store handle rides all strict session +// entries. Tool composition belongs to ui-tool and its machinery spec. import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' @@ -56,7 +53,7 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => { + it('registers the chat view as the first ring entry, declaring the whole-Tool seat', async () => { const b = await bench() const entries = b.slots.entries('conversation.view') expect(entries.map(e => e.options.id)).toEqual(['chat']) @@ -65,7 +62,7 @@ describe('apply wiring', () => { expect(entries[0]?.options.order).toBe(0) // Declaring is claiming: the chat entry's registration put the hole on // the ledger with the contract's kind/scope. - expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' }) + expect(b.slots.spec('conversation.chat.tool')).toEqual({ kind: 'single', scope: 'session' }) await b.runtime.dispose() }) @@ -92,14 +89,13 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the tool rows as keyed entries through declaration injection', async () => { + it('leaves per-Tool rows to the ui-tool plugin', async () => { const b = await bench() // The actual toolview declaration activates every registrant. The // file-mutation registrant claims both write and edit for the diff card; the // one search row registers under both grep and glob; the web rows register // one component under both web tool names. - const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'grep', 'glob', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question']) + expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() @@ -112,8 +108,8 @@ describe('apply wiring', () => { // The declared ring collapses with its declaring entry, and the chat // entry's keyed hole (with the sample's registration) collapses with it. expect(b.slots.entries('conversation.view')).toHaveLength(0) - expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0) - expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined() + expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0) + expect(b.slots.spec('conversation.chat.tool')).toBeUndefined() expect(b.slots.entries('details')).toHaveLength(0) expect(b.slots.entries('settings.general.item')).toHaveLength(0) expect(b.runtime.ctx.get('conversation')).toBeUndefined() diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.spec.tsx similarity index 87% rename from packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx rename to packages/client/ui-conversation/tests/chat-stats.spec.tsx index 7187851420..cdb0ffa8c9 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.spec.tsx @@ -1,26 +1,21 @@ // @vitest-environment jsdom -// StatsLine (composer.dock entry): totals derivation + the RFC -// hard acceptance — zero renders during streaming. Bash sample row: ToolRow -// chrome (Bash · description) without a row click target. +// StatsLine (composer.dock entry): totals derivation + the RFC hard +// acceptance — zero renders during streaming. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { - AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode, + AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' -import { BashRow } from '../src/client/toolviews/bash-sample.tsx' import { en, zh } from '../src/client/locales.ts' -type BashRowProps = Parameters[0] - // Mirrors the real lookup chain (conversation namespace, then common). -const t: BashRowProps['t'] = makeTranslate(zh, commonZh) +const t: StatsLineProps['t'] = makeTranslate(zh, commonZh) const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn) /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ @@ -301,43 +296,3 @@ describe('StatsLine', () => { expect(renders).toBe(before) }) }) - -describe('bash sample row', () => { - const SID = 'root-1' as SessionId - - const result = (callId: string): ToolResultNode => ({ - kind: 'tool-result', seq: 3, time: 3_000, callId, - call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' }, - callTime: 2_000, - content: [], isError: false, callView: null, resultView: null, - }) - - function listStore() { - return createSnapshotStore({ - ids: [SID], - byId: { - [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 }, - }, - current: undefined, - phase: 'ready', - subagentsByParent: {}, - currentAddress: undefined, - }) - } - - const rowProps = (): BashRowProps => ({ - callId: 'c1', toolName: 'bash', block: result('c1'), - openFile: vi.fn(), - sessionId: SID, - useSessions: bindSnapshotSelector(listStore()), - t, - } as unknown as BashRowProps) - - it('summarizes as Bash · description without a row click target', () => { - const view = render() - const row = view.container.querySelector('[data-sample="bash"]')! - expect(row.textContent).toContain('Bash') - expect(row.textContent).toContain('Build') - expect(row.getAttribute('data-clickable')).toBeNull() - }) -}) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index f42aed6731..0a25e9b198 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom // ChatView behavior: flow derivation, streaming isolation (Profiler counts), -// toolview dispatch and selection handoff — driven through a scripted -// ObservableSnapshot fake, no wire. +// Tool seat ownership and selection handoff — driven through a scripted +// ObservableSnapshot fake, no wire or Tool presentation plugin. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Profiler } from 'react' @@ -14,7 +14,7 @@ import type { import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' -import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatViewSlotProps, SelectionTarget, ToolTreeOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' @@ -137,12 +137,25 @@ function makeHarness(init?: Partial) { const forkAt = vi.fn() // Selection rides the REAL chat store (same construction path as // production; the view reads it through the PropsStore useStore share). - // renderSlot stub renders the render-site fallback (an empty keyed ledger: - // every tool lands on GenericToolCard); keyed dispatch to registered rows - // is the slot machinery's behavior, covered by its own specs. const chat = createChatStore().create() - const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => - opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot'] + const t = makeTranslate(zh, commonZh) + const toolOwners: ToolTreeOwnerProps[] = [] + const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + if (key !== 'conversation.chat.tool') return opts?.fallback ?? null + const tool = owner as ToolTreeOwnerProps + toolOwners.push(tool) + // Tool providers own their subtree. The host double carries only the + // semantic anchor required by ChatView's prepend-position contract. + return ( +
+ {tool.toolName || '(unnamed)'}:{tool.callId} +
+ ) + }) as unknown as ChatViewSlotProps['renderSlot'] const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain'] // SessionProvider seat arrives with the session-scope child declaration; @@ -168,10 +181,13 @@ function makeHarness(init?: Partial) { chatScroll, forkAt, // Mirrors the real lookup chain (conversation namespace, then common). - t: makeTranslate(zh, commonZh), + t, } const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) } - return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection } + return { + set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, + chatScroll, forkAt, setSelection, toolOwners, + } } /** Simulate reader input (any device): a delivered position that deviates @@ -374,14 +390,13 @@ describe('chat-flow derivation', () => { }) describe('ChatView', () => { - it('a windowless tool result (call head truncated) renders with an empty tool name', () => { + it('hands a windowless tool result to the Tool seat with an empty tool name', () => { const h = makeHarness({ nodes: [{ ...toolResult(3, 'w1'), call: null }], }) const view = render() - // classifyTool('') → others; the summary slot falls back to the callId. - expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull() - expect(view.getByText('w1')).toBeTruthy() + expect(view.getByTestId('tool-seat-w1')).toBeTruthy() + expect(h.toolOwners[0]).toMatchObject({ callId: 'w1', toolName: '' }) }) it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => { @@ -423,8 +438,8 @@ describe('ChatView', () => { const view = render() expect(view.getByText('do the thing')).toBeTruthy() expect(view.getByText('running tools')).toBeTruthy() - expect(view.getAllByText('Bash')).toHaveLength(2) - expect(view.getByText('run a')).toBeTruthy() + expect(view.getByTestId('tool-seat-a').textContent).toBe('bash:a') + expect(view.getByTestId('tool-seat-b').textContent).toBe('bash:b') expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({ key: row.getAttribute('data-chat-flow-key'), kind: row.getAttribute('data-chat-flow-kind'), @@ -590,14 +605,12 @@ describe('ChatView', () => { ]) }) - it('the expanded row Inspect pill hands the call id to inspectCall', () => { + it('hands the trajectory callback to the Tool seat', () => { const h = makeHarness({ nodes: [toolResult(3, 'a')], }) - const view = render() - fireEvent.click(view.getByRole('button', { name: /Bash/ })) - fireEvent.click(view.getByText('Inspect')) - expect(h.inspectCall).toHaveBeenCalledWith('a') + render() + expect(h.toolOwners[0]?.inspectCall).toBe(h.inspectCall) }) it('shows assistant IconActions only on the last content message of each turn', () => { @@ -822,7 +835,7 @@ describe('ChatView', () => { // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 h.props.renderSlot = ((key: string, _owner: object) => { - if (key !== 'conversation.chat.toolview') return null + if (key !== 'conversation.chat.tool') return null rowRenders += 1 return
}) @@ -838,44 +851,19 @@ describe('ChatView', () => { expect(rowRenders).toBe(afterMount) }) - it('tool row expands to the args body via the whole-row toggle', () => { + it('updates the selected call id handed to the Tool seat', () => { const h = makeHarness({ nodes: [toolResult(3, 'a')] }) - const view = render() - expect(view.queryByText(/"command": "cmd-a"/)).toBeNull() - fireEvent.click(view.container.querySelector('[data-expandable]')!) - expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy() - }) - - it('clicking a bash summary does not open details; selection still marks data-selected', () => { - const h = makeHarness({ nodes: [toolResult(3, 'a')] }) - const view = render() - fireEvent.click(view.getByText('run a')) - expect(h.openDetails).not.toHaveBeenCalled() - expect(h.openFile).not.toHaveBeenCalled() - expect(view.container.querySelector('[data-selected]')).toBeNull() + render() + expect(h.toolOwners.at(-1)?.selectedCallId).toBeUndefined() act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) }) - expect(view.container.querySelector('[data-selected]')).not.toBeNull() + expect(h.toolOwners.at(-1)?.selectedCallId).toBe('a') }) - it('clicking a file-tool path summary opens the host file, not details', () => { - const h = makeHarness({ - nodes: [{ - kind: 'tool-result', seq: 3, time: 3_000, callId: 'r1', - call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' }, - callTime: 2_500, content: [], isError: false, callView: null, resultView: null, - }], - }) - const view = render() - fireEvent.click(view.getByText('src/a.ts')) - expect(h.openFile).toHaveBeenCalledWith('src/a.ts') - expect(h.openDetails).not.toHaveBeenCalled() - }) - - it('running calls render as a live tool group with the running state', () => { + it('hands running calls to a live Tool group', () => { const h = makeHarness({ runningCalls: [runningCall('r1')], running: true }) const view = render() - expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() - expect(view.getByText('cmd-r1')).toBeTruthy() + expect(view.getByTestId('tool-seat-r1')).toBeTruthy() + expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' }) expect(view.getByRole('status').textContent).toBe('Deep diving...') }) @@ -903,19 +891,25 @@ describe('ChatView', () => { expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/) }) - it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => { - const h = makeHarness({ nodes: [toolResult(3, 'a')] }) - const calls: { key: string; entryKey?: string }[] = [] - h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => { - calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) }) + it('hands each ordered root call to the whole-Tool slot', () => { + const block = toolResult(3, 'a') + const h = makeHarness({ nodes: [block] }) + const calls: { key: string; owner: object; entryKey?: string }[] = [] + h.props.renderSlot = ((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => { + calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) }) return opts?.fallback ?? null }) render() - // Keyed dispatch: slot name is the declared hole, entryKey the wire tool - // name, and the fallback (GenericToolCard) renders on an empty ledger. - // (Registered-row takeover and live unload are slot machinery behavior, - // owned by the slot system's own specs.) - expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }]) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + key: 'conversation.chat.tool', + owner: { callId: 'a', toolName: 'bash', selectedCallId: undefined }, + }) + const owner = calls[0]?.owner as ToolTreeOwnerProps + expect(owner.block).toBe(block) + expect(owner.openFile).toBe(h.openFile) + expect(owner.inspectCall).toBe(h.inspectCall) + expect(calls[0]?.entryKey).toBeUndefined() }) it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index c92e43db6c..88e6c04141 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -1,26 +1,17 @@ // @vitest-environment jsdom -// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot, -// bash sample state dots, the node-half empty apply, and AssistantMarkdown -// reasoning/unknown block arms. +// Branch tails the acceptance specs do not reach: the node-half empty apply +// and AssistantMarkdown reasoning/unknown block arms. -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { apply as nodeApply } from '../src/index.ts' -import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx' -import { ToolRow } from '../src/client/chat/ToolRow.tsx' -import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' -import { BashRow } from '../src/client/toolviews/bash-sample.tsx' +import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { zh } from '../src/client/locales.ts' -type BashRowProps = Parameters[0] - // Mirrors the real lookup chain (conversation namespace, then common). -const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) +const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) afterEach(cleanup) @@ -29,14 +20,6 @@ describe('tails', () => { expect(() => { nodeApply() }).not.toThrow() }) - it('ToolRow stopped state renders the warning dot in the leading slot', () => { - const view = render( - } title="Bash" summary="s" body={null} state="stopped" />, - ) - expect(view.queryByTestId('icon')).toBeNull() - expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() - }) - it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { const view = render( { expect(blank.container.firstChild).toBeNull() }) - it('a settled others-variant row renders the sparkle icon in the leading slot', () => { - const settled: ToolResultNode = { - kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5', - call: { name: 'todo_write', argsRaw: '{"note":"x"}' }, - callTime: 1_000, - content: [], isError: false, callView: null, resultView: null, - } - const props: GenericToolCardProps = { - callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t, - } - const view = render() - // Settled ok state keeps the variant icon (sparkle) instead of a StateDot. - expect(view.container.querySelector('[data-variant="others"] svg')).not.toBeNull() - expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() - }) - - it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped', () => { - const sid = 'root-1' as SessionId - const list = createSnapshotStore({ - ids: [sid], - byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, - current: undefined, - phase: 'ready', - subagentsByParent: {}, - currentAddress: undefined, - }) - const props = (block: RunningToolCall | ToolResultNode) => ({ - callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), - sessionId: sid, useSessions: bindSnapshotSelector(list), - t, - } as unknown as BashRowProps) - - const running: RunningToolCall = { - callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}', - turn: 1, step: 1, time: 1_000, callView: null, - } - const errorResult: ToolResultNode = { - kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', - call: { name: 'bash', argsRaw: '{"command":"boom"}' }, - callTime: 500, - content: [], isError: true, callView: null, resultView: null, - } - const stoppedResult: ToolResultNode = { - ...errorResult, - error: { name: 'E', code: 'interrupted' }, - } - - const runningView = render() - expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() - expect(runningView.getByText('Bash')).toBeTruthy() - expect(runningView.getByText('List')).toBeTruthy() - runningView.unmount() - - const errorView = render() - expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull() - expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() - expect(errorView.getByText('失败')).toBeTruthy() - errorView.unmount() - - const stoppedView = render() - expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull() - expect(stoppedView.getByText('已停止')).toBeTruthy() - }) }) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 5adb4d817e..eacd2a754a 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -6,7 +6,8 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots' +import type { DetailsSlotProps, DetailsToolOwnerProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' @@ -33,6 +34,17 @@ afterEach(() => { const SID = 's1' as SessionId +/** Minimal framework seat for direct DetailsPanel host tests. */ +const SessionProviderStub: SessionProviderComponent = ({ children }) => children(SID) + +/** Observe the owner currency without importing the Tool details renderer. */ +function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] { + return (_key, owner) => { + owners?.push(owner as DetailsToolOwnerProps) + return
+ } +} + function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), @@ -95,6 +107,8 @@ describe('render branch tails', () => { }) const view = render( snap, subscribe: () => () => {} })} useSessions={bindSnapshotSelector(emptyList)} @@ -130,8 +144,11 @@ describe('render branch tails', () => { items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) + const owners: DetailsToolOwnerProps[] = [] const view = render( snap, subscribe: () => () => {} })} useSessions={bindSnapshotSelector(emptyList)} @@ -145,10 +162,15 @@ describe('render branch tails', () => { t={t} />, ) - // Sub-call material: the sub-tool name titles the panel, args pretty-print, - // and the COMPLETE logged output renders (no truncation anywhere). + // Conversation resolves the selected sub-call and hands its complete + // frozen block to the Tool-owned details seat. expect(view.getByText('read')).toBeTruthy() - expect(view.getByText(/notes\/demo\.txt/)).toBeTruthy() - expect(view.getByText(longText)).toBeTruthy() + expect(view.getByTestId('tool-details-seat')).toBeTruthy() + expect(owners).toHaveLength(1) + expect(owners[0]?.block).toMatchObject({ + callId: 'p1:code:1', + call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, + content: [{ type: 'text', text: longText }], + }) }) }) diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 3ab95168a7..445d8139e6 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -1,30 +1,20 @@ // @vitest-environment jsdom /** * Todo display acceptance: the TodoPanel plan strip (empty-hidden, status rows - * including several `in_progress` at once, collapse), its TodoDock adapter - * (selects the plan off the session snapshot and follows changes), the row's - * plan summary (counts plus the two halves of the active summary — the named - * task and the `+N` count that parallel work adds, kept apart so the row never - * ellipsizes the count away), and the todo_write toolview row (progress summary - * from args, generic fallback on malformed JSON, shared ToolRow state dots and - * leading expansion). + * including several `in_progress` at once, collapse), and its TodoDock + * adapter (selects the plan off the session snapshot and follows changes). */ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -// Export discipline: packages/client/AGENTS.md. -import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx' import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx' import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx' -import { planSummary } from '../src/client/toolviews/plan-summary.ts' import { NS, zh } from '../src/client/locales.ts' -type TodoRowProps = Parameters[0] - // Mirrors the real lookup chain (conversation namespace, then common). const t: TodoDockProps['t'] = makeTranslate(zh, commonZh) @@ -45,40 +35,6 @@ const PARALLEL: TodoItem[] = [ { content: '补测试', status: 'pending' }, ] -describe('planSummary', () => { - it('counts done/total and names the single active item with no extra count', () => { - expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 }) - }) - - it('reports the extra active count separately when several items are in progress', () => { - // Parallel work marks several: naming one and hiding the rest would lose - // them, and the count stays unjoined so the row cannot ellipsize it. - expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 }) - }) - - it('has no hint when nothing is in progress', () => { - expect(planSummary([{ content: '都完了', status: 'completed' }])) - .toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 }) - }) - - it('has no hint when the first active item carries no usable content (model JSON)', () => { - // Unvalidated args: a missing, mistyped, empty, or whitespace-only content - // yields no hint — and no orphan count, even with a second active item to - // count. Whitespace-only is the tool's own rejection rule (trimmed - // non-empty), and a rejected call keeps its args verbatim. - expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }])) - .toMatchObject({ activeContent: null, activeExtra: 0 }) - expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull() - expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull() - expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }])) - .toMatchObject({ activeContent: null, activeExtra: 0 }) - }) - - it('is empty-safe', () => { - expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 }) - }) -}) - describe('TodoPanel', () => { it('renders nothing while the list is empty', () => { const { container } = render() @@ -178,110 +134,3 @@ describe('TodoDock', () => { expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock) }) }) - -const resultNode = (argsRaw: string, over?: Partial): ToolResultNode => ({ - kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1', - call: { name: 'todo_write', argsRaw }, - content: [], isError: false, callView: null, resultView: null, ...over, -}) - -function rowProps(block: unknown): TodoRowProps { - return { - callId: 'c1', toolName: 'todo_write', block, - openFile: vi.fn(), - sessionId: 's1', - useSessions: () => undefined, - t, - } as unknown as TodoRowProps -} - -describe('TodoRow', () => { - const ARGS = JSON.stringify({ todos: LIST }) - - it('summarizes counts and the active item from the call args', () => { - render() - expect(screen.getByText('更新任务清单')).toBeTruthy() - expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy() - }) - - it('reports the extra active count outside the ellipsized summary text', () => { - const { container } = render() - const text = screen.getByText('1/5 已完成 · 写组件') - const extra = screen.getByText('+2') - // Separate spans: .summary truncates, the count must not travel inside it. - expect(text.contains(extra)).toBe(false) - expect(container.textContent).toContain('1/5 已完成 · 写组件+2') - }) - - it('omits the active clause when no item is in progress and reads running-call args', () => { - const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] }) - render() - expect(screen.getByText('1/1 已完成')).toBeTruthy() - }) - - it('keeps the counts when an active item has unusable content, instead of the generic summary', () => { - // planSummary yields activeContent null here, but the counts are known good, - // so the row drops only the active clause — `?? model.summary` never runs. - const args = JSON.stringify({ todos: [{ content: 'done', status: 'completed' }, { content: 42, status: 'in_progress' }] }) - const { container } = render() - expect(screen.getByText('1/2 已完成')).toBeTruthy() - expect(container.textContent).not.toContain('+') - }) - - it('keeps the non-ok execution states visible through the shared row states', () => { - // A running call (no result yet) carries the running state (row sweep). - const args = JSON.stringify({ todos: LIST }) - const running = render() - expect(running.container.querySelector('[data-state="running"]')).not.toBeNull() - expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull() - running.unmount() - // A cancelled call wrote no todo/write: the row must not read as a completed update. - const stopped = render() - expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull() - }) - - it('falls back to the generic summary on malformed args and marks the error state', () => { - const view = render() - expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() - // Generic others summary: " · ". - expect(screen.getByText('todo_write · not json')).toBeTruthy() - }) - - it('falls back when parsed args carry no todos array', () => { - render() - expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy() - }) - - it('leading toggle expands the raw args body', () => { - render() - fireEvent.click(screen.getByRole('button', { expanded: false })) - expect(screen.getByRole('button', { expanded: true })).toBeTruthy() - // The expanded body is the pretty-printed args, not the tool output. - expect(screen.getByText(/搭骨架/)).toBeTruthy() - }) - - it.each([ - { label: 'null root', argsRaw: 'null' }, - { label: 'non-object root', argsRaw: '42' }, - { label: 'null items', argsRaw: '{"todos":[null]}' }, - ])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => { - render() - // No throw, and the generic others summary carries the raw args verbatim. - expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy() - }) - - it('window-truncated result (call head lost) falls back to the callId summary', () => { - render() - expect(screen.getByText('todo_write · c1')).toBeTruthy() - }) - - it('todoToolview injects the toolview declaration directly', () => { - expect(todoToolview.name).toBe('todo-toolview') - expect(todoToolview.inject).toEqual(['slots']) - const register = vi.fn(() => () => undefined) - const inject = vi.fn((_name: string, callback: () => () => void) => callback()) - todoToolview.apply({ slots: { inject, register } } as never) - expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function)) - expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow) - }) -}) diff --git a/packages/client/ui-conversation/tests/views-type-chain.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.spec.tsx index 69e8355870..b055c51560 100644 --- a/packages/client/ui-conversation/tests/views-type-chain.spec.tsx +++ b/packages/client/ui-conversation/tests/views-type-chain.spec.tsx @@ -1,16 +1,11 @@ -// View-ring + toolview-hole type-chain samples, slot form: both are declared -// slots, so the register→inject→render chain and its compile-time locks are -// the slot system's (ui-slots/tests/type-chain.spec.tsx owns the generic -// duals). This spec pins the package-specific surface: the SlotMap rows -// (kind/scope/owner), list- and keyed-kind registration shapes, the ChatView -// and tool-row composed-props contracts, and the runtime dual — a real -// SlotsService ledger driving registration/order/disposal the way -// ConversationRoot's tab projection consumes it. +// View-ring type-chain samples. This spec pins the conversation-owned SlotMap +// row, list-kind registration shape, composed view props, and the runtime +// ledger projection consumed by ConversationRoot. import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { ReactNode } from 'react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatViewSlotProps, ConvViewProps, ToolRowProps } from '../src/client/contract/slots.ts' +import type { ChatViewSlotProps, ConvViewProps } from '../src/client/contract/slots.ts' describe('view-ring type negatives (compile-time; body never runs)', () => { it('holds the negative samples as expect-error sites', () => { @@ -54,30 +49,6 @@ describe('view-ring type negatives (compile-time; body never runs)', () => { return null } void chatProps - // 7. Keyed hole registration requires the key shape field. - // @ts-expect-error missing `key` on a keyed-slot registration - slots.register({ name: 'conversation.chat.toolview' }, (_p: ToolRowProps) => null) - // 8. A list-kind shape field is rejected on the keyed hole. - slots.register( - // @ts-expect-error `id`/`order` belong to list slots, not the keyed hole - { name: 'conversation.chat.toolview', key: 'k', order: 1 }, - (_p: ToolRowProps) => null) - // 9. Tool-row components stay within their composed contract: the - // owner share + standard kit supply no chat-view members. - const overreaching = (props: ToolRowProps): ReactNode => { - // @ts-expect-error loadOlder lives on ChatViewSlotProps, not the row contract - void props.loadOlder - return null - } - void overreaching - // 10. Owner-share drift is red at the row component seam: block is the - // call union, not arbitrary payload. - const drifted = (props: ToolRowProps): ReactNode => { - // @ts-expect-error the block union has no `argsParsed` member - void props.block.argsParsed - return null - } - void drifted return null as ReactNode } expect(negatives).toBeTypeOf('function') diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index a1d1a2c9d5..5aa997d003 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md -README.md: bdd772662acda1f8cf1b7d8a7c5532f9b37123dd -README.zh.md: 959ff0ede6d545150fb22710c8af75859966caa9 +README.md: 44953fe36ad337d0dd70e4d8c0cc2372b8924c9b +README.zh.md: 8c21ef35eded61324d139dd32b7c1e38f8709d55 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index bdd772662a..44953fe36a 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -12,7 +12,7 @@ The `/client` export surface is the plugin body (`apply`/`inject`) only; the sou ## Skill tool row -The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 14-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from a paired call/result slice in the current runtime window, never from the current catalog, so replay remains stable when installed skills or their descriptions change. +The browser plugin also registers the `skill` wire name in `ui-tool`'s keyed `tool.call.toolview` slot. A collapsed row renders the 14-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by `ui-tool`, never from the current catalog, so replay remains stable when installed skills or their descriptions change. ## Model Experience diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 959ff0ede6..8c21ef35ed 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -12,7 +12,7 @@ pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文 ## skill 工具行 -浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 14 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自当前 runtime 窗口中已配对的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 +浏览器插件还会把 `skill` wire 名称注册进 `ui-tool` 的 keyed `tool.call.toolview` slot。收起的行以与 Bash 行相同的中性色层级显示 14 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自 `ui-tool` 提供的冻结 call/result slice,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 ## 模型体验 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index c9d2dd4ed8..420b1c4322 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -26,7 +26,7 @@ "inject": [ "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-tool", "@deepseek-ai/dsh-client-ui-slash" ], "platform": "web" @@ -40,7 +40,7 @@ "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-tool": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", @@ -53,7 +53,7 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index 65b474825a..a26c41ada5 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -6,7 +6,7 @@ import { useState, type KeyboardEvent, type ReactNode } from 'react' import { IconChevronDownOutline14, IconInspectOutline12, IconSkillOutline16, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import css from './SkillRow.module.css' @@ -14,7 +14,7 @@ import css from './SkillRow.module.css' type SkillRowState = 'running' | 'ok' | 'error' | 'stopped' /** Full row props: the toolview runtime share plus this package's locale seat. */ -type SkillRowProps = ToolRowProps & PropsLocale<'skill'> +type SkillRowProps = ToolCallViewProps & PropsLocale<'skill'> /** Compact, replay-stable view model for the dedicated row. */ interface SkillRowModel { @@ -45,9 +45,9 @@ function skillName(argsRaw: string, callId: string): string { return argsRaw === '' ? callId : firstLine(argsRaw) } -/** Flatten durable result blocks under the generic tool-row text contract. - * Keep aligned with ui-conversation's contract/tool-call-model.ts `resultText`. */ -function resultText(block: ToolRowProps['block']): string | null { +/** Flatten durable result blocks under the generic Tool-row text contract. + * Keep aligned with ui-tool's models/tool-call-model.ts `resultText`. */ +function resultText(block: ToolCallViewProps['block']): string | null { if (!('kind' in block)) return null const parts: string[] = [] for (const item of block.content) { @@ -60,7 +60,7 @@ function resultText(block: ToolRowProps['block']): string | null { } /** Derive display state without consulting the live skill catalog. */ -function skillRowModel(block: ToolRowProps['block']): SkillRowModel { +function skillRowModel(block: ToolCallViewProps['block']): SkillRowModel { const settled = 'kind' in block const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? '' const state: SkillRowState = !settled diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 4e23be06be..139398b648 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -58,8 +58,8 @@ export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale'] */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-skill: dictionaries') - ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register( - { name: 'conversation.chat.toolview', key: 'skill', locale: NS }, + ctx.slots.inject('tool.call.toolview', () => ctx.slots.register( + { name: 'tool.call.toolview', key: 'skill', locale: NS }, SkillRow, )) diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index f73a8d8bda..4679ef2c98 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -41,7 +41,7 @@ function providePresentation(ctx: Context): PresentationCapture { const slots = new SlotsService(ctx) slots.register({ name: 'root', - children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } }, + children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' } }, } as never, () => null) const capture: PresentationCapture = { slots, @@ -113,7 +113,7 @@ describe('apply', () => { ctx.provide('sessions', { subagentAddress: () => undefined }) const presentation = providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() - const entry = presentation.slots.entries('conversation.chat.toolview')[0] + const entry = presentation.slots.entries('tool.call.toolview')[0] expect(entry?.options).toMatchObject({ key: 'skill' }) expect(entry?.locale).toBe('skill') expect(entry?.component).toBe(SkillToolRow) @@ -158,7 +158,7 @@ describe('apply', () => { // …and fiber teardown releases it. await fiber.dispose() expect(() => slash.registerSource(rival)).not.toThrow() - expect(presentation.slots.entries('conversation.chat.toolview')).toHaveLength(0) + expect(presentation.slots.entries('tool.call.toolview')).toHaveLength(0) expect(presentation.localeDisposed).toBe(true) }) }) diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index f83486aa36..d6ec931648 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -21,7 +21,7 @@ "path": "../runtime" }, { - "path": "../ui-conversation" + "path": "../ui-tool" }, { "path": "../ui-primitives" diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml new file mode 100644 index 0000000000..828cfa25ef --- /dev/null +++ b/packages/client/ui-tool/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md +README.md: 381253f4eddaa57b89318dd23da3a049505fdd15 +README.zh.md: ae539131198771bc1d0e280bbfaa76ec0ec60792 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md new file mode 100644 index 0000000000..381253f4ed --- /dev/null +++ b/packages/client/ui-tool/README.md @@ -0,0 +1,44 @@ +# @deepseek-ai/dsh-client-ui-tool + +English | [中文](README.zh.md) + +Client Tool presentation plugin. `ui-conversation` supplies one ordered root call through `conversation.chat.tool`; this package renders that root and its Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card. + +Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and `codeDispatches`; the conversation view remains authoritative for ChatFlow placement. + +## Rendering contract + +`ToolCallTree` receives one root `ToolCallBlock`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. Through its standard session slot props it selects the Runtime-projected `codeDispatches[rootCallId]` array, then sends the root and every child through the same atomic dispatch path. The Runtime currently exposes only one Code Dispatch child level, so the renderer preserves that shape instead of inventing recursive data. + +The package also fills `conversation.details.tool` with `ToolDetails`. The row and details renderers share the same pure card models for `terminal`, `read`, `diff`, `search`, and `web` render intents. Unknown intent tags and malformed wire card data fall back to flattened Tool result text. + +Generic rows classify known Tool names into search, read, shell, write, edit, code, or generic variants. Running, successful, failed, and interrupted lifecycle states come only from the frozen call/result slice. File paths resolve against the session `cwd` only when the user invokes the Host open-file callback; presentation code does not read Session services. + +## Atomic Tool views + +An owning business package registers its wire Tool name into `tool.call.toolview`: + +```ts ignore-check +ctx.slots.inject('tool.call.toolview', () => + ctx.slots.register({ + name: 'tool.call.toolview', + key: '', + }, BusinessToolRow)) +``` + +The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd`, and plain `openFile`/`inspect` callbacks. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge. + +This package currently owns the generic fallback and the built-in bash/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`. + +## Model Experience + +None. This package renders already logged Tool calls and results and does not alter model requests, Tool execution, or session events. + +#### KV Cache effect + +None. The package is client-only presentation. + +## Known Limitations and Deferred Work + +- The Runtime currently exposes one level of Code Dispatch children. The renderer sends roots and children through the same atomic path, but it does not claim an arbitrary recursive wire topology. +- Existing first-party Tool views are initially colocated here and can move to their owning business packages independently through the keyed slot. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md new file mode 100644 index 0000000000..ae53913119 --- /dev/null +++ b/packages/client/ui-tool/README.zh.md @@ -0,0 +1,44 @@ +# @deepseek-ai/dsh-client-ui-tool + +[English](README.md) | 中文 + +Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交付一个已经排好位置的 root call;本包渲染该 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。 + +业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript,也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和 `codeDispatches`;conversation view 继续负责 ChatFlow 位置。 + +## 渲染契约 + +`ToolCallTree` 接收一个 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,再让 root 与每个 child 经过同一条原子分发路径。Runtime 当前只暴露一层 Code Dispatch child,因此 renderer 保留该形状,不自行发明递归数据。 + +本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与详情 renderer 为 `terminal`、`read`、`diff`、`search` 和 `web` render intent 共用同一组纯 card model。本版本不认识的 intent 标签和格式错误的 wire card 数据都会回退为压平的 Tool result 文本。 + +通用行把已知 Tool 名称归类为 search、read、shell、write、edit、code 或 generic 变体。运行中、成功、失败和中断状态只来自冻结的 call/result slice。只有用户调用 Host 打开文件回调时,文件路径才相对会话 `cwd` 解析;展示代码不读取 Session service。 + +## 原子 Tool 视图 + +业务所有方把自己的 wire Tool 名称注册进 `tool.call.toolview`: + +```ts ignore-check +ctx.slots.inject('tool.call.toolview', () => + ctx.slots.register({ + name: 'tool.call.toolview', + key: '', + }, BusinessToolRow)) +``` + +owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd`,以及普通的 `openFile`/`inspect` 回调。注册项会收到正常的 Session slot runtime share,但不会收到 React node、Runtime service 或 root/subcall 知识。 + +本包当前拥有 generic fallback,以及 bash/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包如何拥有 `skill` 注册。 + +## 模型体验 + +无。本包只渲染已经记录的 Tool 调用和结果,不改变模型请求、Tool 执行或 Session Event。 + +#### KV Cache 影响 + +无。本包只负责 Client 展示。 + +## 已知限制与后续工作 + +- Runtime 当前只暴露一层 Code Dispatch 子调用。renderer 会让 root 和 child 经过同一个原子分发路径,但不宣称 wire 拓扑已经支持任意递归。 +- 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json new file mode 100644 index 0000000000..965a084cd2 --- /dev/null +++ b/packages/client/ui-tool/package.json @@ -0,0 +1,73 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-tool", + "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts new file mode 100644 index 0000000000..48a9a4c812 --- /dev/null +++ b/packages/client/ui-tool/src/client/apply.ts @@ -0,0 +1,43 @@ +/** Register the Tool call tree, details renderer, and built-in atomic views. */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ToolCallTree } from './tool/ToolCallTree.tsx' +import { ToolDetails } from './tool/ToolDetails.tsx' +import { CONVERSATION_NS as NS } from './locale.ts' +import { askQuestionToolview } from './tool/toolviews/ask-question-row.tsx' +import { bashToolviewSample } from './tool/toolviews/bash-sample.tsx' +import { fileMutationToolview } from './tool/toolviews/file-mutation-row.tsx' +import { readToolview } from './tool/toolviews/read-row.tsx' +import { searchToolview } from './tool/toolviews/search-row.tsx' +import { todoToolview } from './tool/toolviews/todo-row.tsx' +import { webToolview } from './tool/toolviews/web-row.tsx' + +/** Required service: the slot registry that owns both Tool render seats. */ +export const inject = ['slots'] + +/** + * Mount the whole-Tool renderers and built-in atomic Tool registrations. + * @param ctx - Client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.slots.inject('conversation.chat.tool', () => ctx.slots.register({ + name: 'conversation.chat.tool', + locale: NS, + children: { + 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, + }, + }, ToolCallTree)) + + ctx.slots.inject('conversation.details.tool', () => ctx.slots.register({ + name: 'conversation.details.tool', + locale: NS, + }, ToolDetails)) + + ctx.plugin(bashToolviewSample) + ctx.plugin(readToolview) + ctx.plugin(fileMutationToolview) + ctx.plugin(searchToolview) + ctx.plugin(webToolview) + ctx.plugin(todoToolview) + ctx.plugin(askQuestionToolview) +} diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts new file mode 100644 index 0000000000..4b74055b2c --- /dev/null +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -0,0 +1,39 @@ +/** Tool UI slot declarations and their composed component props. */ +import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** Keyed atomic Tool call view, dispatched by the wire Tool name. */ + 'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps } + } +} + +/** Standard owner currency supplied to every atomic Tool view. */ +export interface ToolCallOwnerProps { + /** Tool call identity, stable across running and settled forms. */ + callId: string + /** Wire Tool name and keyed dispatch value. */ + toolName: string + /** Frozen running call or settled result node. */ + block: ToolCallBlock + /** Session workspace root for relative summaries. */ + cwd?: string | undefined + /** Open a Tool argument path through the Host. */ + openFile: (path: string) => void + /** Inspect this call in the trajectory view when available. */ + inspect?: (() => void) | undefined +} + +/** Full props of a registered atomic Tool view. */ +export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> + +/** Full props of the Tool call-tree renderer registered into the chat flow. */ +export type ToolTreeProps = PropsRuntime<'conversation.chat.tool'> + & PropsRenderSlots<'tool.call.toolview'> + & PropsLocale<'conversation'> + +/** Full props of the selected Tool output renderer in the details panel. */ +export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts new file mode 100644 index 0000000000..357506b1db --- /dev/null +++ b/packages/client/ui-tool/src/client/index.ts @@ -0,0 +1,3 @@ +/** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ +export { apply, inject } from './apply.ts' +export type { ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolTreeProps } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/locale.ts b/packages/client/ui-tool/src/client/locale.ts new file mode 100644 index 0000000000..0dd6721101 --- /dev/null +++ b/packages/client/ui-tool/src/client/locale.ts @@ -0,0 +1,2 @@ +/** Locale namespace supplied by the conversation owner to Tool renderers. */ +export const CONVERSATION_NS = 'conversation' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.module.css b/packages/client/ui-tool/src/client/tool/ToolCallTree.module.css new file mode 100644 index 0000000000..b33fb477c1 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.module.css @@ -0,0 +1,12 @@ +.callRow { + border-radius: 6px; +} + +.subCalls { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 0 2px 22px; + padding-left: 8px; + border-left: 1px solid var(--dsw-alias-border-l2); +} diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx new file mode 100644 index 0000000000..8091f26dff --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -0,0 +1,88 @@ +/** Root/subcall Tool composition with one keyed atomic dispatch path. */ +import { memo, useMemo } from 'react' +import type { CodeSubCall, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts' +import { GenericToolCard } from './toolviews/GenericToolCard.tsx' +import css from './ToolCallTree.module.css' + +/** Resolve a Code Dispatch child's wire Tool name from either lifecycle form. */ +function subCallName(node: CodeSubCall): string { + return 'kind' in node ? node.call?.name ?? '' : node.name +} + +/** One atomic call dispatched through the Tool-owned keyed slot. */ +const ToolCall = memo(function ToolCall({ + renderSlot, callId, toolName, block, openFile, selected, cwd, inspectCall, t, +}: Pick & { + callId: string + toolName: string + block: ToolCallBlock + selected: boolean +}) { + const owner: ToolCallOwnerProps = useMemo(() => ({ + callId, + toolName, + block, + openFile, + cwd, + inspect: () => { inspectCall(callId) }, + }), [callId, toolName, block, openFile, cwd, inspectCall]) + return ( +
+ {renderSlot('tool.call.toolview', owner, { + entryKey: toolName, + fallback: , + })} +
+ ) +}) + +/** + * Render one root Tool call and its currently supported one-level Code + * Dispatch children. Root and children use the same atomic keyed dispatch. + * @param props - whole-Tool owner data and the Tool-owned child-slot share. + * @returns the Tool call tree. + */ +export function ToolCallTree({ + useSession, renderSlot, callId, toolName, block, selectedCallId, cwd, openFile, inspectCall, t, +}: ToolTreeProps) { + const subCalls = useSession(snapshot => snapshot.codeDispatches.get(callId)) + return ( + <> + + {subCalls !== undefined && subCalls.length > 0 ? ( +
+ {subCalls.map(node => ( + + ))} +
+ ) : null} + + ) +} diff --git a/packages/client/ui-tool/src/client/tool/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/ToolDetails.module.css new file mode 100644 index 0000000000..ebfb3afb07 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/ToolDetails.module.css @@ -0,0 +1,46 @@ +.description { + margin: 0 0 6px; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); +} + +.cardBody { + margin: 0; +} + +.recovery { + margin: 6px 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} + +.code { + margin: 0; + padding: 16px; + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + white-space: pre-wrap; + word-break: break-word; +} + +.code[data-error] { + color: var(--dsw-alias-state-error-primary); +} + +.read, +.web { + margin: 0; +} + +.empty { + padding: 8px 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx new file mode 100644 index 0000000000..f496801daa --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx @@ -0,0 +1,66 @@ +/** Card-aware output body for the selected Tool call in details. */ +import { DiffBlock, ReadBlock, SearchBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolDetailsProps } from '../contract/slots.ts' +import { diffCardModel } from './models/diff-card-model.ts' +import { readCardModel } from './models/read-card-model.ts' +import { searchCardModel } from './models/search-card-model.ts' +import { terminalBlockLabels, terminalCardModel } from './models/terminal-card-model.ts' +import { resultText } from './models/tool-call-model.ts' +import { webCardModel } from './models/web-card-model.ts' +import css from './ToolDetails.module.css' + +/** Pure details-body inputs; framework session seats stay at the slot boundary. */ +interface ToolDetailsContentProps { + block: ToolDetailsProps['block'] + cwd?: ToolDetailsProps['cwd'] + t: ToolDetailsProps['t'] +} + +/** + * Render the selected Tool call's structured output when its presentation + * intent is known, otherwise preserve the flattened result text. + * @param props - selected call slice, workspace root, and locale seat. + * @returns the details output body. + */ +export function ToolDetails({ block, cwd, t }: ToolDetailsContentProps) { + const terminal = terminalCardModel(block, cwd) + if (terminal !== null) { + return ( + <> + {terminal.description !== undefined ? ( +
{terminal.description}
+ ) : null} + + + ) + } + const read = readCardModel(block, cwd) + if (read !== null) return + const diff = diffCardModel(block) + if (diff !== null) return + const search = searchCardModel(block) + if (search !== null) { + return ( + <> + + {search.recovery !== undefined ?
{search.recovery}
: null} + + ) + } + const web = webCardModel(block) + if (web !== null) { + const body = 'kind' in block ? resultText(block) : '' + return ( + <> + + {body !== '' ?
{body}
: null} + + ) + } + if (!('kind' in block)) return
{t('details.running')}
+ return ( +
+      {resultText(block)}
+    
+ ) +} diff --git a/packages/client/ui-tool/src/client/tool/components/DisclosureRow.module.css b/packages/client/ui-tool/src/client/tool/components/DisclosureRow.module.css new file mode 100644 index 0000000000..04f2d18d0a --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/DisclosureRow.module.css @@ -0,0 +1,69 @@ +/* Shared Tool calls disclosure header: [16px leading] gap 6 [title 14/24]. */ + +.root { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; +} + +.row { + position: relative; + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +.row[data-expandable] { + cursor: pointer; +} + +.leading { + position: relative; + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + padding: 0; + border: none; + background: none; + color: var(--dsw-alias-label-tertiary); +} + +button.leading { + cursor: pointer; +} + +.iconIdle { + display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; +} + +.chevronHover { + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; +} + +.row:hover .iconIdle { + opacity: 0; +} + +.row:hover .chevronHover { + opacity: 1; +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-tool/src/client/tool/components/DisclosureRow.tsx b/packages/client/ui-tool/src/client/tool/components/DisclosureRow.tsx new file mode 100644 index 0000000000..361fb24517 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/DisclosureRow.tsx @@ -0,0 +1,104 @@ +import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import clsx from 'clsx' +import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './DisclosureRow.module.css' + +/** Shared 24px disclosure chrome for conversation flow rows. */ +export interface DisclosureRowProps { + icon: ReactNode + title: string + open: boolean + expandable: boolean + onToggle: () => void + /** Makes the complete title row the disclosure target. */ + expandOnRowClick?: boolean | undefined + /** Replaces the collapsed icon with a chevron while the row is hovered. */ + previewChevron?: boolean | undefined + /** Keeps `collapsedContent` inline while open (ToolRow's summary stays readable next to the expanded card). */ + keepContentWhenOpen?: boolean | undefined + collapsedContent?: ReactNode + children?: ReactNode + className?: string | undefined + rowClassName?: string | undefined + leadingClassName?: string | undefined + chevronClassName?: string | undefined + titleClassName?: string | undefined +} + +/** + * Render one disclosure header and its controlled expanded content. + * @param props - Visual content, controlled state, and interaction policy. + * @returns The disclosure row. + */ +export function DisclosureRow({ + icon, + title, + open, + expandable, + onToggle, + expandOnRowClick = false, + previewChevron = expandable, + keepContentWhenOpen = false, + collapsedContent, + children, + className, + rowClassName, + leadingClassName, + chevronClassName, + titleClassName, +}: DisclosureRowProps) { + const rowExpands = expandable && expandOnRowClick + const toggleFromLeading = (event: MouseEvent) => { + event.stopPropagation() + onToggle() + } + const toggleFromKeyboard = (event: KeyboardEvent) => { + if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + onToggle() + } + const collapsedLeading = previewChevron + ? ( + <> + {icon} + + + ) + : icon + const leading = open + ? + : collapsedLeading + + return ( +
+
+ {expandable && !rowExpands ? ( + + ) : ( + + {leading} + + )} + {title} + {(keepContentWhenOpen || !open) && collapsedContent} +
+ {open && children} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css similarity index 94% rename from packages/client/ui-conversation/src/client/chat/ToolRow.module.css rename to packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 36f4c2b76a..9d9bce9eed 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -84,11 +84,6 @@ color: var(--dsw-alias-label-tertiary); } -/* Live reasoning follows its one-line summary to the inline end. */ -.summary[data-follow-end] { - text-overflow: clip; -} - /* Trailing summary fragment kept out of .summary's ellipsis, for a count whose whole value is that it survives a narrow row (the todo row's parallel-active `+n`). Repeats .summary's type because it sits beside that text, and its @@ -184,19 +179,6 @@ overflow-y: auto; } -/* Think expanded body: plain indented gray reasoning prose — no IN/OUT card - (the reasoning is not an input payload), pre-wrapped at the row's indent. - Uncapped: reasoning reads as message prose, so it flows with the page - instead of scrolling in a box. */ -.thinkBody { - padding: 4px 0 4px 22px; - font-size: 14px; - line-height: 24px; - white-space: pre-wrap; - word-break: break-word; - color: var(--dsw-alias-label-tertiary); -} - /* Expanded input/output card (figma 1249:35657): the code-block surface and radius from the TerminalBlock/CodeBlock family. The card itself is a plain column — the padding and the IN/OUT gutter-label grid live on each section diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx similarity index 76% rename from packages/client/ui-conversation/src/client/chat/ToolRow.tsx rename to packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 48c0c825cb..61c677ea98 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -4,36 +4,32 @@ // DisclosureRow chrome with the whole row as the expand toggle (click / // Enter / Space, icon→chevron hover preview). The collapsed row is always // one line; every row with body, output, or a card material (terminal, diff, -// read, search, web) is expandable; the summary stays inline while open, -// except Think, where the running collapsed row follows the latest line at its -// scroll end and the summary yields while open to avoid repeating the body. +// read, search, web) is expandable; the summary stays inline while open. // The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for // text input/output, the run_code program through CodeBlock, or a card // primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a // call that declared that render intent — lives in a max-height scroll // container so a long payload scrolls internally instead of taking over the -// message flow; Think's prose is the exception and flows uncapped like message -// text. Every card kind starts collapsed, so a run of tool calls stays +// message flow. Every card kind starts collapsed, so a run of tool calls stays // scannable; the details panel is the single-call full-height reading surface. // Expand state is component-local view state. File-tool summaries are path // links that open through the host (stopPropagation keeps the two gestures // independent); an error row's collapsed summary is the failure's first line in // the error color. -import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { CodeBlock, DiffBlock, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts' -import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts' -import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts' -import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts' -import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' +import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts' +import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts' +import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts' +import { terminalBlockLabels, type TerminalCardModel } from '../models/terminal-card-model.ts' +import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts' import { DisclosureRow } from './DisclosureRow.tsx' -import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -101,8 +97,7 @@ export interface ToolRowProps { onOpenFile?: ((path: string) => void) | undefined /** * Jump to this call in the trajectory view: a hover-revealed Inspect pill - * over the expanded body. Absent = no affordance (rows without a call - * identity, like Think). + * over the expanded body. Absent = no affordance. */ inspect?: (() => void) | undefined } @@ -153,7 +148,6 @@ export function ToolRow({ inspect, }: ToolRowProps) { const [expanded, setExpanded] = useState(false) - const summaryRef = useRef(null) const terminalBody = terminal ?? null const diffBody = diff ?? null const readBody = read ?? null @@ -178,19 +172,6 @@ export function ToolRow({ const suffix = failureLine === null ? summarySuffix ?? null : null // The failure line is error prose, not the path: no open-file affordance. const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null - const isThink = variant === 'think' - const followSummaryEnd = isThink && state === 'running' && !open - const scheduleSummaryScroll = useThrottledVisualUpdate(() => { - const summaryElement = summaryRef.current - if (summaryElement === null) return - summaryElement.scrollLeft = followSummaryEnd - ? summaryElement.scrollWidth - summaryElement.clientWidth - : 0 - }) - useEffect(() => { - if (!isThink) return - scheduleSummaryScroll() - }, [followSummaryEnd, isThink, scheduleSummaryScroll, summaryText]) const toggleExpand = () => { setExpanded(v => !v) } @@ -205,9 +186,6 @@ export function ToolRow({ const fileLinkKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() } - // Think reasoning is prose, not an input payload: expanded, it renders as - // plain indented text (no IN/OUT card) and the inline summary yields to avoid - // repeating the body. // The code variant's program renders through CodeBlock (shiki), so only its // output joins the IN/OUT card; every other variant's input does too. const cardBody = variant === 'code' ? null : body @@ -227,7 +205,7 @@ export function ToolRow({ open={open} expandable={expandable} expandOnRowClick - keepContentWhenOpen={!isThink} + keepContentWhenOpen onToggle={toggleExpand} collapsedContent={summaryText !== '' && ( /* An empty summary drops the separator with it (a row that is only @@ -245,9 +223,7 @@ export function ToolRow({ ) : ( {summaryText} @@ -285,38 +261,36 @@ export function ToolRow({ ) : webBody !== null ? - : isThink - ?
{body}
- : ( - <> - {variant === 'code' && body !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- IN - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- OUT - - {outputText} - -
- )} -
- )} - - )} + : ( + <> + {variant === 'code' && body !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ IN + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ OUT + + {outputText} + +
+ )} +
+ )} + + )} {inspect !== undefined && ( )) const view = b.runtime.renderRoot() @@ -191,7 +193,7 @@ describe('keyed toolview hole through the real machinery', () => { }) describe('registrant declaration injection', () => { - it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => { + it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) @@ -204,8 +206,8 @@ describe('registrant declaration injection', () => { let applyRuns = 0 const registrantApply = (registrantCtx: typeof runtime.ctx): void => { applyRuns += 1 - registrantCtx.slots.inject('conversation.chat.toolview', () => registrantCtx.slots.register( - { name: 'conversation.chat.toolview', key: 'late' }, () => null)) + registrantCtx.slots.inject('tool.call.toolview', () => registrantCtx.slots.register( + { name: 'tool.call.toolview', key: 'late' }, () => null)) } const late = runtime.ctx.plugin({ name: 'late-registrant', @@ -215,11 +217,12 @@ describe('registrant declaration injection', () => { await Promise.resolve() await late.await() expect(applyRuns).toBe(1) - expect(runtime.slots.entries('conversation.chat.toolview')).toHaveLength(0) + expect(runtime.slots.entries('tool.call.toolview')).toHaveLength(0) // Mounting the package declares the slot and activates the waiting entry. - await runtime.mount({ inject: [...inject], apply }) - expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key)) + await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + await runtime.mount({ inject: [...injectTool], apply: applyTool }) + expect(runtime.slots.entries('tool.call.toolview').map(e => e.options.key)) .toEqual(expect.arrayContaining(['bash', 'late'])) await runtime.dispose() }) diff --git a/packages/client/ui-tool/tests/toolview-type-chain.spec.tsx b/packages/client/ui-tool/tests/toolview-type-chain.spec.tsx new file mode 100644 index 0000000000..b1aa7b9464 --- /dev/null +++ b/packages/client/ui-tool/tests/toolview-type-chain.spec.tsx @@ -0,0 +1,34 @@ +// The Tool-owned keyed-slot type chain: registration shape and composed +// atomic-view props. Generic slot-system duals live in ui-slots tests. +import { describe, expect, it } from 'vitest' +import type { ReactNode } from 'react' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallViewProps } from '../src/client/contract/slots.ts' + +describe('toolview type negatives (compile-time; body never runs)', () => { + it('holds the negative samples as expect-error sites', () => { + const negatives = (slots: SlotsService) => { + // Keyed registration requires the key shape field. + // @ts-expect-error missing `key` on a keyed-slot registration + slots.register({ name: 'tool.call.toolview' }, (_p: ToolCallViewProps) => null) + slots.register( + // @ts-expect-error `id`/`order` belong to list slots, not the keyed hole + { name: 'tool.call.toolview', key: 'k', order: 1 }, + (_p: ToolCallViewProps) => null) + const overreaching = (props: ToolCallViewProps): ReactNode => { + // @ts-expect-error loadOlder belongs to the conversation host, not an atomic Tool view + void props.loadOlder + return null + } + void overreaching + const drifted = (props: ToolCallViewProps): ReactNode => { + // @ts-expect-error the Tool call union has no pre-parsed args member + void props.block.argsParsed + return null + } + void drifted + return null as ReactNode + } + expect(negatives).toBeTypeOf('function') + }) +}) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx similarity index 94% rename from packages/client/ui-conversation/tests/web-card.spec.tsx rename to packages/client/ui-tool/tests/web-card.spec.tsx index 220661ff88..743b450ca7 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -16,15 +16,17 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { webCardModel } from '../src/client/contract/web-card-model.ts' -import { createChatStore } from '../src/client/stores.ts' -import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' -import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' -import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx' +import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import { webCardModel } from '../src/client/tool/models/web-card-model.ts' +import { createChatStore } from '../../ui-conversation/src/client/stores.ts' +import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' +import { DetailsPanel } from '../../ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' +import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { zh } from '../src/client/locales.ts' +import { zh } from '../../ui-conversation/src/client/locales.ts' afterEach(cleanup) @@ -121,7 +123,7 @@ describe('webCardModel', () => { }) describe('chat row web body', () => { - const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({ + const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolCallOwnerProps => ({ callId: block.callId, toolName, block, openFile: vi.fn(), }) // WebRow reads only toolName/block off the full runtime share plus the locale @@ -214,6 +216,8 @@ describe('DetailsPanel web Output section', () => { }) return render( snapshot, subscribe: () => () => {} })} useSessions={bindSnapshotSelector(sessions)} diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json new file mode 100644 index 0000000000..17516295fb --- /dev/null +++ b/packages/client/ui-tool/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../runtime" + }, + { + "path": "../locale" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-tool/tsdown.config.ts b/packages/client/ui-tool/tsdown.config.ts new file mode 100644 index 0000000000..1c66514f9a --- /dev/null +++ b/packages/client/ui-tool/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-tool', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d604545dfd..ccbc67c764 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1284,6 +1284,9 @@ importers: '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme + '@deepseek-ai/dsh-client-ui-tool': + specifier: workspace:^ + version: link:../../client/ui-tool '@deepseek-ai/dsh-client-ui-trajectory': specifier: workspace:^ version: link:../../client/ui-trajectory @@ -2201,9 +2204,6 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../test-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -2213,6 +2213,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-tool': + specifier: workspace:^ + version: link:../ui-tool '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2355,6 +2358,55 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-tool: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-trajectory: dependencies: '@tanstack/react-virtual': diff --git a/tsconfig.base.json b/tsconfig.base.json index 75719f1ecc..de9b63459c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -162,6 +162,7 @@ "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], + "@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"], "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 2a2b16e2e7..9ce72753c1 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -59,6 +59,7 @@ { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-conversation" }, + { "path": "./packages/client/ui-tool" }, { "path": "./packages/client/ui-deliverables" }, { "path": "./packages/client/ui-workspace" }, { "path": "./packages/client/ui-slash" }, diff --git a/vitest.config.ts b/vitest.config.ts index f5a86ac7a9..597bca5618 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -162,6 +162,7 @@ export default defineConfig({ 'packages/client/web-react/src/*', 'packages/client/runtime/src/*', 'packages/client/ui-conversation/src/*', + 'packages/client/ui-tool/src/*', 'packages/client/ui-slots/src/*', 'packages/client/ui-layout/src/*', 'packages/client/web/src/*', From fafd54fc03d1d44e3bab07cedeb913d8e636e077 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:19:27 +0800 Subject: [PATCH 086/100] docs(client): record Tool presentation ownership --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 24 ++-- ...26-07-19-gui-web-client-architecture.zh.md | 24 ++-- .../2026-07-23-toolview-dissolution.i18n.yaml | 4 +- .../2026-07-23-toolview-dissolution.md | 16 ++- .../2026-07-23-toolview-dissolution.zh.md | 16 ++- ...ient-tool-presentation-ownership.i18n.yaml | 6 + ...8-08-client-tool-presentation-ownership.md | 103 ++++++++++++++++++ ...8-client-tool-presentation-ownership.zh.md | 103 ++++++++++++++++++ 9 files changed, 256 insertions(+), 44 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md create mode 100644 .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 5376a626e6..2dccd44aae 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 1a91d88818c374a1637b546fb3ddf6647af68570 -2026-07-19-gui-web-client-architecture.zh.md: 5c0bacde9836d45812895f5d9c89a0e8974ed7a1 +2026-07-19-gui-web-client-architecture.md: 4dc4558ea245baa17646f92b9b5e4c9a45b6a419 +2026-07-19-gui-web-client-architecture.zh.md: a306b5c82891840b96340f5464267cc9d861ef7e diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 1a91d88818..4dc4558ea2 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency, independently from `ConversationService` ([decision](2026-08-05-slot-declaration-injection.md)). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. +There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: ui-conversation places each ordered root call into the single `'conversation.chat.tool'` seat and passes the Runtime-projected Code Dispatch children without interpreting their Tool names; ui-tool renders that root/child shape and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and both roots and children dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). @@ -86,22 +86,24 @@ The glue package is the whole ctx↔React boundary; components stay framework-fr ## Directory shape -Twelve `packages/client/*` packages (ui-slots, ui-primitives, web-react, connection, runtime, ui-layout, ui-sidebar, ui-conversation, ui-trajectory, ui-theme, i18n, web) plus `apps/web` — the vite application, a thin `main` over the shell's boot export. Plugin packages keep their browser half under `src/client/`; **every build artifact lands in `lib/`** — the node half as `lib/index.js`/`lib/invariant.js`, the browser bundle as `lib/client.js` (the shared tsdown client preset emits both; there is no `dist/` directory, and `exports["./client"]` points at `./lib/client.js`). Dependency direction: `ui-slots ← web-react ← runtime ← ui-* (peers) ← web`, with ui-primitives/ui-theme/i18n as zero-dependency side paths. +Client packages live under `packages/client/*`, with `apps/web` as the thin Vite application over the shell's boot export. Plugin packages keep their browser half under `src/client/`; **every build artifact lands in `lib/`** — the node half as `lib/index.js`/`lib/invariant.js`, the browser bundle as `lib/client.js` (the shared tsdown client preset emits both; there is no `dist/` directory, and `exports["./client"]` points at `./lib/client.js`). `ui-slots`, web-react, and runtime form the infrastructure direction; feature plugins cooperate through services and slots rather than importing presentation implementations. A multi-domain plugin package additionally splits its client half by future package boundaries — ui-conversation is the exemplar: ``` src/client/ - contract/ the only shared face between domains (types + composed props shares) - service.ts cross-domain orchestration (imports contract only) - skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel) - chat/ domain: the chat view - toolviews/ domain: sample tool-row registrants (third-party posture) - apply.ts the ONLY file allowed to import across domains (assembly point) - index.ts thin re-export shell (contract + apply + components) + contract/ shared slot and cross-domain types + service.ts cross-domain orchestration + skeleton/ conversation shell and details host + chat/ ordered conversation view + input/ composer state machine + queue/ queued-message presentation + settings/ conversation settings rows + apply.ts cross-domain assembly point + index.ts public contract surface ``` -Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. the toolviews samples take `ToolRowProps` from the contract, never chat internals). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths. +Domain implementation files never import a sibling domain; shared surfaces route through `contract/`. `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). Tool presentation is already a separate `ui-tool` package and reaches chat and details only through the slots ui-conversation declares. ## How to develop @@ -122,5 +124,5 @@ Token streams no longer shake the render tree: a frame storm costs unsubscribed | One statically-linked SPA bundle | Plugins must be host-composable at runtime (config-driven); a monolith re-couples every UI feature to one build | | window globals / import maps for shared deps | The DI require table keeps sharing explicit, fail-loud, and swappable; globals leak identity and version silently | | Business data in zustand slices | The event window/accumulator is a behavioral state machine, not a flat slice; the object layer keeps snapshot granularity and batching controllable | -| String-keyed global component registry for tool rows | Per-view keyed child slots plus in-component session branching carry the same need with the one registration model; a parallel registry does not come back ([toolview dissolution](2026-07-23-toolview-dissolution.md)) | +| Parallel string-keyed component registry for Tool rows | ui-tool's keyed child slot carries the runtime-open Tool-name set through the one slot registration model ([toolview dissolution](2026-07-23-toolview-dissolution.md)) | | Progressive/Suspense boot in P-I | One-flip boot is strictly simpler; the loader's per-plugin status face is kept so progressive lighting can land later without re-architecture | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 5c0bacde98..a306b5c828 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain- 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 -slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`;声明本身就是加载与重载依赖,不依赖 `ConversationService`([决策](2026-08-05-slot-declaration-injection.md))。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 +slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:ui-conversation 把每个已排序 root call 放进 single `'conversation.chat.tool'` seat,并透传 Runtime 已投影的 Code Dispatch child,不解释其 Tool 名称;ui-tool 渲染该 root/child 形状,并声明 keyed/session 的 `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与 child 都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 @@ -86,22 +86,24 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── ## 目录形态 -十二个 `packages/client/*` 包(ui-slots、ui-primitives、web-react、connection、runtime、ui-layout、ui-sidebar、ui-conversation、ui-trajectory、ui-theme、i18n、web)加 `apps/web`——vite 应用,壳 boot 导出之上的薄 `main`。插件包的浏览器半边在 `src/client/` 下;**一切构建产物落 `lib/`**——node 半边为 `lib/index.js`/`lib/invariant.js`,浏览器 bundle 为 `lib/client.js`(共享 tsdown client 预设两者皆出;无 `dist/` 目录,`exports["./client"]` 指向 `./lib/client.js`)。依赖方向:`ui-slots ← web-react ← runtime ← ui-*(并列)← web`,ui-primitives/ui-theme/i18n 为零依赖旁路。 +Client 包位于 `packages/client/*`,`apps/web` 是壳 boot 导出之上的薄 Vite 应用。插件包的浏览器半边在 `src/client/` 下;**一切构建产物落 `lib/`**——node 半边为 `lib/index.js`/`lib/invariant.js`,浏览器 bundle 为 `lib/client.js`(共享 tsdown client 预设两者皆出;无 `dist/` 目录,`exports["./client"]` 指向 `./lib/client.js`)。`ui-slots`、web-react 与 runtime 构成基础设施方向;功能插件通过 service 与 slot 协作,不导入展示实现。 多域插件包的 client 半边还按未来包边界再拆——ui-conversation 即样板: ``` src/client/ - contract/ the only shared face between domains (types + composed props shares) - service.ts cross-domain orchestration (imports contract only) - skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel) - chat/ domain: the chat view - toolviews/ domain: sample tool-row registrants (third-party posture) - apply.ts the ONLY file allowed to import across domains (assembly point) - index.ts thin re-export shell (contract + apply + components) + contract/ shared slot and cross-domain types + service.ts cross-domain orchestration + skeleton/ conversation shell and details host + chat/ ordered conversation view + input/ composer state machine + queue/ queued-message presentation + settings/ conversation settings rows + apply.ts cross-domain assembly point + index.ts public contract surface ``` -域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 toolviews 样例从契约取 `ToolRowProps`,永不碰 chat 内部)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。 +各领域实现文件不 import 兄弟领域;共享面统一经过 `contract/`。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、domain=1、apply/index=2;import 只准指向不高于自身的层级;兄弟领域依赖会失败)。Tool 展示已经拆为独立 `ui-tool` 包,只通过 ui-conversation 声明的 slot 到达 chat 与 details。 ## 怎么开发 @@ -122,5 +124,5 @@ token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位 | 静态链接的单 SPA bundle | 插件必须由 host 在运行时按配置组合;单体把每个 UI 功能重新耦回一次构建 | | window 全局变量 / import map 供共享依赖 | DI require 表让共享显式、大声失败、可替换;全局变量静默泄漏身份与版本 | | 业务数据进 zustand 切片 | 事件窗口/累积器是行为状态机,不是扁平切片;对象层保住快照粒度与合批的可控性 | -| 工具行走字符串键的全局组件注册表 | per-view keyed 子槽 + 组件内会话分支以唯一注册模型承载同一需求;平行 registry 不复活([toolview 溶解](2026-07-23-toolview-dissolution.md)) | +| Tool 行使用平行的字符串键组件注册表 | ui-tool 的 keyed 子 slot 通过唯一的 slot 注册模型承载运行时开放的 Tool 名称集合([toolview 溶解](2026-07-23-toolview-dissolution.md)) | | P-I 就做渐进/Suspense 启动 | 一次成型严格更简单;loader 的按插件状态面已保留,渐进点亮日后可落地而无需重构 | diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml index 90d13fcfea..09f0c27732 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md -2026-07-23-toolview-dissolution.md: 97d8beb4de43d9bc6348d942e5460d0321592b32 -2026-07-23-toolview-dissolution.zh.md: db93c6252d5d42d1fd85ce81ad430d95f4324cf2 +2026-07-23-toolview-dissolution.md: be1bd9d161714194855988d76a632c667fae8c84 +2026-07-23-toolview-dissolution.zh.md: afe5e03e8345fca2a0f097d86873974f6d417ff2 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index 97d8beb4de..be1bd9d161 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-23-toolview-dissolution.zh.md) -> Scope: why the standalone tool ring (ToolViewRegistry/ctx.toolviews/outlet) was retired and what replaced it. The [web client architecture note](2026-07-19-gui-web-client-architecture.md) carries the shipped-state narrative this decision produced; the [slot system standard](2026-07-22-slot-type-chain-implementation.md) owns the registration model everything now runs on. +> Scope: why the standalone tool ring (ToolViewRegistry/ctx.toolviews/outlet) was retired and what replaced it. The [web client architecture note](2026-07-19-gui-web-client-architecture.md) carries the shipped-state narrative this decision produced; the [slot system standard](2026-07-22-slot-type-chain-implementation.md) owns the registration model everything now runs on. The later [Client Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) decision supersedes only this note's per-view placement: Tool-name dispatch remains a keyed slot rather than a parallel registry. ## Problem @@ -14,24 +14,22 @@ After the view ring dissolved into the slot system, the client kept exactly one The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. -Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin using `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`; the declaration itself governs activation and replacement, without a false `ConversationService` edge ([decision](2026-08-05-slot-declaration-injection.md)). The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. - -Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. +This decision originally placed `'conversation.chat.toolview'` under the chat entry and made the chat render site dispatch each row. The follow-up [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) moves that placement into a whole-Tool seat and gives `ui-tool` one keyed `'tool.call.toolview'` child slot. That follow-up changes the presentation owner, not this decision's core constraint: Tool registration continues to use ordinary keyed-slot machinery, with framework-owned activation, replacement, caching, error isolation, versioning, and fallback behavior. ## Accepted semantic changes -Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance is per-view registration — a row must adapt to each view's layout anyway, so one registration per view is the correct coupling, and reuse is the same component in two register calls. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch, when a row needs it, belongs inside the component (the standard kit already carries `useSessions`), not in registry predicates — there is no shipped session-variant exemplar today. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry. +Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance was initially per-view registration; the follow-up note records why root/subcall composition later justified one Tool-wide presentation owner. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch, when a row needs it, belongs inside the component (the standard kit already carries `useSessions`), not in registry predicates — there is no shipped session-variant exemplar today. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry. ## Alternatives considered -**Keep the standalone registry (the original shape).** Rejected: each of its multi-dimensional dispatch axes has a more correct home — the view dimension belongs to each view's own declared child slot (declaring is claiming, so specialization ownership lands right), and the session dimension belongs inside the component, which already holds the standard kit. What remained after both moves was a second copy of slot machinery with no distinguishing capability. +**Keep the standalone registry (the original shape).** Rejected: each of its multi-dimensional dispatch axes has a more correct home — presentation ownership belongs to an explicitly declared child slot, and the session dimension belongs inside the component, which already holds the standard kit. What remains is a second copy of slot machinery with no distinguishing capability. -**Promote `renderToolView` into the standard kit and move the registry into the runtime package.** Rejected: "tool row" is a conversation-domain concept; hoisting it into runtime would leak a domain vocabulary into the framework layer and still leave two registration models. +**Promote `renderToolView` into the standard kit and move the registry into the runtime package.** Rejected: Tool presentation is Client UI vocabulary; hoisting it into runtime would leak presentation into the data object layer and still leave two registration models. **Derive slot declarations from subscription refCounts** (declare the slot implicitly when the first registrant subscribes). Rejected for implicit coupling and debounce complexity; noted as a possible revisit only if a genuinely multi-viewer surface appears. -**A thin `registerToolView` facade over slots.register.** Deferred, not rejected: after dissolution the facade would carry only compile-time sugar (slot-name literal narrowing, tool→key vocabulary, props pre-composition) with zero runtime. Per "enforce at the operation boundary" (a facade is not an enforcement point) and "don't split preemptively" (today's registrant population is one bash sample), it stays unbuilt; the type sugar ships as the exported `ToolRowProps` alias. Regret clause: if registrants grow to three-to-five or a bulk-registration pattern appears, the facade is ten lines added without disturbing direct registration. +**A thin `registerToolView` facade over slots.register.** Deferred, not rejected: after dissolution the facade would carry only compile-time sugar (slot-name literal narrowing, tool→key vocabulary, props pre-composition) with zero runtime. Per "enforce at the operation boundary" (a facade is not an enforcement point), it stays unbuilt; the useful type composition ships as the exported Tool view props alias. A later facade can be added without disturbing direct registration if repeated registration ceremony justifies it. ## Consequences -The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override). Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention. +The client has one registration model; auditing who renders Tool calls means reading slot register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above, chiefly loud duplicate-key failure and no third-party registry-level override. Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index db93c6252d..afe5e03e83 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-23-toolview-dissolution.md) | 中文 -> 范围:独立工具环(ToolViewRegistry/ctx.toolviews/outlet)为何退役、被什么取代。本决策产出的落地态叙述归 [Web 客户端架构注](2026-07-19-gui-web-client-architecture.md);一切现在所运行其上的注册模型归 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 所有。 +> 范围:独立工具环(ToolViewRegistry/ctx.toolviews/outlet)为何退役、被什么取代。本决策产出的落地态叙述归 [Web 客户端架构注](2026-07-19-gui-web-client-architecture.md);一切现在所运行其上的注册模型归 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md)所有。后续的 [Client Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)决策仅取代本篇的 per-view 放置方式:Tool 名称分发仍使用 keyed slot,而非平行注册表。 ## Problem @@ -14,24 +14,22 @@ Status: implemented 工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 -落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方是使用 `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))` 的普通插件;声明本身控制激活与替换,不再引入虚假的 `ConversationService` 依赖([决策](2026-08-05-slot-declaration-injection.md))。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 - -registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。 +本决策最初把 `'conversation.chat.toolview'` 放在 chat 条目下,由 chat 渲染点逐行分发。后续的 [Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)引入整体 Tool 席位,并让 `ui-tool` 拥有唯一的 keyed `'tool.call.toolview'` 子 slot。后续决策改变的是展示所有者,而非本决策的核心约束:Tool 注册继续使用普通 keyed-slot 机制,激活、替换、缓存、错误隔离、版本与 fallback 行为仍归框架所有。 ## 接受的语义变化 -四项行为增量是刻意接受而非疏漏。跨视图出场=逐视图注册——行本须适配各视图版式,一视图一注册是正确耦合,复用即同一组件写两次 register。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发若行需要,归组件内部(标配 kit 已带 `useSessions`),不走注册表谓词——今天没有已落地的会话变体样例。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。 +四项行为增量是刻意接受而非疏漏。跨视图出场最初采用逐视图注册;后续 Note 记录了 root/subcall 编排为何足以支持一个 Tool 级展示所有者。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发若行需要,归组件内部(标配 kit 已带 `useSessions`),不走注册表谓词——今天没有已落地的会话变体样例。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。 ## Alternatives considered -**保留独立注册表(原形态)。** 拒绝:其多维分发的每一维都有更正确的家——视图维归各视图自己声明的子槽(declaring is claiming,特化面权属自然落对),会话维归已持有标配 kit 的组件内部。两步移完后剩下的只是一份没有任何独有能力的 slot 机器副本。 +**保留独立注册表(原形态)。** 拒绝:其多维分发的每一维都有更正确的家——展示所有权归显式声明的子 slot,会话维归已持有标配 kit 的组件内部。两步移完后剩下的只是一份没有任何独有能力的 slot 机器副本。 -**把 `renderToolView` 提进标配 kit、注册表迁入 runtime 包。** 拒绝:「工具行」是 conversation 域概念;上提进 runtime 会把域词汇泄漏进框架层,且依然留着两套注册模型。 +**把 `renderToolView` 提进标配 kit、注册表迁入 runtime 包。** 拒绝:Tool 展示是 Client UI 词汇;上提进 runtime 会把展示概念泄漏进数据对象层,且依然留着两套注册模型。 **以订阅 refCount 推导槽声明**(首个注册方订阅时隐式声明槽)。拒绝:隐式耦合加去抖复杂度;记为将来真出现多观看面时的备选。 -**slots.register 之上的薄 `registerToolView` 门面。** 缓建而非拒绝:溶解后该门面只剩编译期三糖(槽名字面量收窄、tool→key 词汇翻译、props 预组合),运行时为零。按「enforce at the operation boundary」(门面不是强制点)与「don't split preemptively」(今天注册方人口只有一个 bash 样例)保持不建;类型糖以导出的 `ToolRowProps` 别名兑现。后悔药条款:注册方长到三五家或出现批量注册模式时,门面十行可补,不扰直注。 +**slots.register 之上的薄 `registerToolView` 门面。** 缓建而非拒绝:溶解后该门面只剩编译期语法糖(slot 名字面量收窄、tool→key 词汇翻译、props 预组合),运行时为零。按「enforce at the operation boundary」(门面不是强制点)保持不建;有用的类型组合以导出的 Tool view props 别名兑现。若重复注册仪式今后足以证明其价值,可在不扰动直接注册的前提下补充门面。 ## Consequences -client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖)。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定。 +client 只有一种注册模型;审计谁渲染 Tool 调用就是读 slot register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化,主要是重复 key 会 loud failure,且第三方无 registry 级覆盖。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml new file mode 100644 index 0000000000..47adf75d40 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md +2026-08-08-client-tool-presentation-ownership.md: 4d06450a10c4198f20d7139aef815def9e7cb362 +2026-08-08-client-tool-presentation-ownership.zh.md: 3d3bf3bc3204aca6871a82c7b7ae330b6381a3e4 diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md new file mode 100644 index 0000000000..4d06450a10 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md @@ -0,0 +1,103 @@ +# Agent Note: Client Tool presentation ownership + +Status: implemented + +English | [中文](2026-08-08-client-tool-presentation-ownership.zh.md) + +## Problem + +The Client Runtime already projects Tool calls into a stable lifecycle: it pairs call/result events by `callId`, preserves running and settled forms, and indexes Code Dispatch children by their root call. The chat view nevertheless owned the entire presentation stack. It placed root calls in ChatFlow, composed each root with its subcalls, dispatched every atomic call by Tool name, carried the generic fallback and card models, registered first-party Tool views, and reused those models in the details panel. + +That ownership made `ui-conversation` interpret business Tool names and made subcalls an orphaned concern if an atomic Tool view moved elsewhere. A business package such as `ui-skill` could register a row, but it still depended on conversation's Tool-specific composition contract. Adding Tool-specific Session projection would duplicate a data model the Runtime already owns, while moving only individual React components would leave the composition and model coupling in place. + +## Decision + +Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Session Event, projection, fold, `ConversationSnapshot` construction and caching, historical paging, and Code Dispatch indexing remain unchanged. + +“First-class concept” describes UI ownership only; it adds no Runtime data kind. `ConversationNode` remains the transcript projection, `ChatFlowItem` remains the render unit produced when conversation sorts and groups nodes, `ToolCallBlock` remains the standard data for one call, and `ToolCallTree` only composes root/subcall presentation within Tool. Command continues to render through the separate `'conversation.chat.commandview'` seat and does not become Tool. + +`ui-conversation` owns ordered placement. `deriveChatFlow()` still decides where a settled Tool group appears, and `ChatView` still appends running calls, maintains scroll anchors and selection, and supplies host actions. For each root call it renders the single/session `'conversation.chat.tool'` seat with the root block, selected call id, session cwd, and open-file/inspect callbacks. It does not read Code Dispatch children, branch on Tool names, or import Tool-specific views and card models. + +`ui-tool` occupies that whole-Tool seat. Through its standard session slot props, `ToolCallTree` selects the Runtime-projected `codeDispatches[rootCallId]` array, renders the root followed by that one currently supported child level, and routes both forms through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. This is deliberately one-level composition, not a claim that the Runtime supports an arbitrary recursive call graph. + +Business plugins register only atomic views against `'tool.call.toolview'`. Their owner payload is the standard Tool call block plus identity, cwd, and host actions; it carries no Session projector or conversation service. Skill remains an ordinary Tool and `ui-skill` registers the `skill` key through this seam. Existing first-party views live in `ui-tool` until a business package has a reason to own one independently. + +The details panel is a second Tool presentation site but not a call-tree owner. `ui-conversation` delegates its selected output body through the single/session `'conversation.details.tool'` seat; `ui-tool` renders the card-aware output and the seat fallback preserves raw result text when the plugin is absent. Card models therefore have one production owner without introducing a reverse implementation import. + +The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch stays a top-level official concept because it changes `codeDispatches` and parent/child identity; ordinary Tool business differences stay at the keyed presentation seam. This package boundary does not add a Tool projector/fold registry. + +## Runtime and render path + +This boundary starts at the Client's `ConversationSnapshot`; the full render path is: + +```text +ConversationSnapshot.nodes + -> deriveChatFlow() + -> settled tool-group positions ----+ + | +ConversationSnapshot.runningCalls | + -> ChatView flow tail ---------------+-> ToolSeat + -> conversation.chat.tool + -> ToolCallTree +ConversationSnapshot.codeDispatches[rootCallId] -+ + +-> root ToolCall + one-level child ToolCall + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback +``` + +The live Session's [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) caches arrays or maps such as `nodes`, `runningCalls`, and `codeDispatches` against independent revisions. Their references stay stable when the corresponding business state has not changed, allowing React selectors and memoization to skip unrelated updates. The historical projection's [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) reconstructs the same running-call and Code Dispatch shapes from entries in its window. Tool UI consumes the snapshot shapes already unified by those paths; presentation packages do not repeat call/result pairing, historical replay, or cache indexing. + +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. `ToolCallTree` selects only the current root's `codeDispatches[rootCallId]`; it does not introduce a business projector for presentation of other roots. + +## Code and responsibility boundaries + +| Owner | Primary code | Owns | Explicitly does not own | +|---|---|---|---| +| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, Code Dispatch parent/child index, snapshot reference stability | Business views selected by Tool name | +| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts), [`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx), [`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow order, settled groups, running tail, scroll anchors, selection and host actions, whole-Tool seat declaration | subcall composition, `toolName` dispatch, Generic fallback, Tool card models | +| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts), [`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx), [`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall composition, atomic keyed dispatch, Generic fallback, Tool card models and built-in Tool views | ChatFlow ordering, Session Event fold | +| Business Tool plugins | [`ui-skill` registration example](../../../../packages/client/ui-skill/src/client/index.ts) | Atomic views for one or more wire Tool names | root/subcall placement and lifecycle pairing | +| Details path | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx), [`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected-call lookup, card-aware output, and raw fallback | chat call-tree composition | + +## Slot and owner contract + +A slot declaration also constrains render ownership. The conversation chat entry declares `'conversation.chat.tool'` through `children`, so only `ChatView` places the whole-Tool seat. When `ui-tool` registers that seat, its `children` declares `'tool.call.toolview'`, so only `ToolCallTree` renders the atomic Tool seat. Business plugins register keyed entries only; they neither participate in root/subcall composition nor establish a registry parallel to slots. + +The whole seat's `ToolTreeOwnerProps` carries the root `callId`, `toolName`, `ToolCallBlock`, `selectedCallId`, session `cwd`, `openFile(path)`, and `inspectCall(callId)`. `ToolCallTree` converts either a root or child into the same `ToolCallOwnerProps` and narrows inspect to a callback for that call. The atomic owner carries no `ReactNode`, Cordis `Context`, Session service, or projector; a business view consumes only one standard call block and host actions. + +Business plugins use one registration shape: + +```text +ctx.slots.inject('tool.call.toolview', () => + ctx.slots.register({ + name: 'tool.call.toolview', + key: '', + }, BusinessToolRow)) +``` + +`ui-tool`'s [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the whole-Tool renderer, details renderer, and existing built-in atomic views. An existing independent business package can move only its keyed registration, as `ui-skill` does, without changing `ui-conversation` or Session. + +## Details path + +[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) still locates the selected call in `nodes`, `runningCalls`, and `codeDispatches`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse. + +## Verification + +Test ownership follows production ownership. `ui-conversation` tests install a local whole-Tool seat probe and assert only ChatFlow placement, owner payload, and host contracts such as selection, open-file, and inspect; they do not import `ui-tool` production code or test helpers. `ui-tool` tests mount a real conversation host and verify root/subcall composition, keyed dispatch, generic fallback, concrete Tool UI, and plugin lifecycle. + +## Alternatives considered + +**Keep atomic Tool slots under every conversation view.** Rejected: each view would have to reproduce root/subcall composition, and a Tool registration would be isolated by view even though its business meaning is Tool-wide. A whole-Tool seat preserves view-owned placement while giving the call tree one owner. This supersedes the per-view placement selected by the earlier [toolview dissolution](2026-07-23-toolview-dissolution.md), while retaining its keyed-slot and no-parallel-registry decisions. + +**Move only the Tool React components and card models.** Rejected: `ChatView` would still own Tool-name dispatch and Code Dispatch composition, so the dependency would change file paths without changing responsibility. + +**Add business-specific Session projectors or folds.** Rejected: ordinary Tool views consume the standard call block already reconstructed by Runtime. A second registry would create two authorities for call identity and historical replay. Only a feature that changes logged topology or lifecycle earns a Runtime-level extension. + +**Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Root/child composition belongs to `ui-tool`, and the current wire/runtime shape only supports one Code Dispatch child level. + +**Import `ui-tool` components directly from `ui-conversation`.** Rejected: it would reverse the intended feature direction and make Tool presentation mandatory. Declared slots retain lifecycle ownership, fallback behavior, and independent plugin loading. + +## Consequences + +`ui-conversation` becomes independent of Tool-name business presentation while retaining ChatFlow, selection, and host interaction responsibilities. Root calls and subcalls cannot drift onto different dispatch paths, and business packages can own atomic Tool presentation without Session changes. The cost is one new Client package and two cross-package slot seams; `ui-tool` also deliberately depends on conversation's declared seats and locale namespace. The assembled Web bundle therefore mounts `ui-tool`; omitting it leaves chat Tool seats empty while the details seat keeps its raw-result fallback, without changing Session reconstruction. diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md new file mode 100644 index 0000000000..3d3bf3bc32 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md @@ -0,0 +1,103 @@ +# Agent Note: Client Tool 展示所有权 + +Status: implemented + +[English](2026-08-08-client-tool-presentation-ownership.md) | 中文 + +## Problem + +Client Runtime 已经把 Tool 调用投影成稳定的生命周期:它按 `callId` 配对 call/result 事件,保留 running 与 settled 两种形态,并按 root call 索引 Code Dispatch 子调用。但 chat view 仍拥有整套展示链路:它在 ChatFlow 中放置 root call,把每个 root 与 subcall 编排在一起,按 Tool 名称分发每个原子调用,携带通用 fallback 与 card model,注册第一方 Tool view,并在 details panel 中复用这些 model。 + +这种所有权迫使 `ui-conversation` 解释业务 Tool 名称;一旦原子 Tool view 被迁走,subcall 就会成为无主的遗留关注点。`ui-skill` 等业务包虽能注册一行视图,仍依赖 conversation 的 Tool 专属编排契约。增加 Tool 专属 Session projection 会重复 Runtime 已拥有的数据模型,而只移动单个 React 组件则会把编排与 model 耦合留在原地。 + +## Decision + +Tool 成为 Client UI 的一级概念,并由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Session Event、projection、fold、`ConversationSnapshot` 构建与缓存、历史分页及 Code Dispatch 索引保持不变。 + +这里的“一级概念”只描述 UI 所有权,不增加 Runtime 数据种类。`ConversationNode` 仍是 transcript projection,`ChatFlowItem` 仍是 conversation 对节点进行排序与分组后得到的渲染单元,`ToolCallBlock` 仍是单次调用的标准数据,而 `ToolCallTree` 只负责 Tool 内部的 root/subcall 展示编排。Command 继续通过独立的 `'conversation.chat.commandview'` 席位渲染,不并入 Tool。 + +`ui-conversation` 拥有有序放置。`deriveChatFlow()` 仍决定 settled Tool group 在哪里出现,`ChatView` 仍追加 running call、维护滚动 anchor 与 selection,并提供宿主动作。对于每个 root call,它使用 root block、selected call id、session cwd 以及 open-file/inspect 回调渲染 single/session 的 `'conversation.chat.tool'` 席位。它不读取 Code Dispatch child、不按 Tool 名称分支,也不导入 Tool 专属 view 或 card model。 + +`ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,先渲染 root,再渲染当前支持的一层 child;两种调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。这里刻意只编排一层,并不声称 Runtime 已支持任意递归调用图。 + +业务插件只对 `'tool.call.toolview'` 注册原子 view。其 owner payload 是标准 Tool call block 加 identity、cwd 与宿主动作,不携带 Session projector 或 conversation service。Skill 仍是普通 Tool,`ui-skill` 通过该 seam 注册 `skill` key。现有第一方 view 暂留在 `ui-tool`,直到某个业务包确有理由独立拥有它。 + +details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 通过 single/session 的 `'conversation.details.tool'` 席位委托 selected output body;`ui-tool` 渲染能够识别 card 的输出,插件缺席时由席位 fallback 保留 raw result text。因此 card model 只有一个生产代码所有者,也不需要引入反向实现依赖。 + +Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 会改变 `codeDispatches` 与 parent/child identity,因此继续作为官方顶级概念;普通 Tool 业务差异停留在 keyed 展示 seam。这个包边界不会增加 Tool projector/fold registry。 + +## Runtime 与渲染链路 + +这项边界从 Client 的 `ConversationSnapshot` 开始,完整渲染链路如下: + +```text +ConversationSnapshot.nodes + -> deriveChatFlow() + -> settled tool-group positions ----+ + | +ConversationSnapshot.runningCalls | + -> ChatView flow tail ---------------+-> ToolSeat + -> conversation.chat.tool + -> ToolCallTree +ConversationSnapshot.codeDispatches[rootCallId] -+ + +-> root ToolCall + one-level child ToolCall + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback +``` + +Live Session 的 [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 按独立 revision 缓存 `nodes`、`runningCalls`、`codeDispatches` 等数组或 map;没有对应业务变化时,它们保持引用稳定,供 React selector 与 memo 跳过无关更新。历史 projection 的 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 从窗口内 entry 重建相同的 running call 与 Code Dispatch 形态。Tool UI 直接消费这两个路径已经统一的 snapshot,不在展示包中重复 call/result 配对、历史 replay 或缓存索引。 + +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`;running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。`ToolCallTree` 只选择当前 root 的 `codeDispatches[rootCallId]`,不会因其他 root 的展示逻辑引入业务 projector。 + +## 代码与职责边界 + +| 所有者 | 主要代码 | 拥有的责任 | 明确不拥有 | +|---|---|---|---| +| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、Code Dispatch parent/child 索引、snapshot 引用稳定性 | Tool 名称对应的业务视图 | +| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts)、[`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx)、[`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow 顺序、settled group、running tail、scroll anchor、selection 与宿主动作、整体 Tool 席位声明 | subcall 组合、按 `toolName` 分发、Generic fallback、Tool card model | +| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts)、[`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx)、[`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall 组合、原子 keyed dispatch、Generic fallback、Tool card model 与内置 Tool view | ChatFlow 排序、Session Event fold | +| 业务 Tool 插件 | [`ui-skill` 注册例](../../../../packages/client/ui-skill/src/client/index.ts) | 一个或多个 wire Tool name 的原子 view | root/subcall 位置与生命周期配对 | +| details 路径 | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx)、[`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected call 定位、card-aware output 与 raw fallback | chat 调用树编排 | + +## Slot 与 owner 契约 + +slot 声明同时限定渲染所有权。conversation chat entry 通过 `children` 声明 `'conversation.chat.tool'`,因此只有 `ChatView` 放置整体 Tool 席位;`ui-tool` 注册该席位时再通过 `children` 声明 `'tool.call.toolview'`,因此只有 `ToolCallTree` 渲染原子 Tool 席位。业务插件只注册 keyed entry,不参与 root/subcall 编排,也不建立与 slot 平行的 registry。 + +整体席位的 `ToolTreeOwnerProps` 携带 root `callId`、`toolName`、`ToolCallBlock`、`selectedCallId`、session `cwd`、`openFile(path)` 与 `inspectCall(callId)`。`ToolCallTree` 把 root 或 child 转成相同的 `ToolCallOwnerProps`,并把 inspect 收窄成当前 call 的回调。原子 owner 不携带 `ReactNode`、Cordis `Context`、Session service 或 projector;业务 view 只消费一个标准调用块和宿主动作。 + +业务插件遵循同一个注册形态: + +```text +ctx.slots.inject('tool.call.toolview', () => + ctx.slots.register({ + name: 'tool.call.toolview', + key: '', + }, BusinessToolRow)) +``` + +`ui-tool` 的 [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册整体 Tool renderer、details renderer 与现有内置原子 view;已有独立业务包可以像 `ui-skill` 一样只迁走自己的 keyed 注册,无需改动 `ui-conversation` 或 Session。 + +## Details 路径 + +[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 仍从 `nodes`、`runningCalls` 与 `codeDispatches` 中定位选中的 call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`;[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result text,running call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。 + +## Verification + +测试归属跟随生产所有权。`ui-conversation` 的测试安装本地整体 Tool 席位替身,只验证 ChatFlow 位置、owner payload 与 selection、open-file、inspect 等宿主契约;它们不导入 `ui-tool` 的生产实现或测试 helper。`ui-tool` 的测试挂载真实 conversation 宿主,验证 root/subcall 编排、keyed dispatch、generic fallback、具体 Tool UI 与插件生命周期。 + +## Alternatives considered + +**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都必须重复 root/subcall 编排,而且 Tool 注册会按 view 隔离,即使它的业务语义本应是 Tool 级。整体 Tool 席位保留 view 对放置位置的所有权,同时让调用树只有一个所有者。它取代了早期 [toolview 溶解](2026-07-23-toolview-dissolution.md)所选择的 per-view 放置方式,但保留 keyed slot 与不设平行 registry 的决策。 + +**只移动 Tool React 组件与 card model。** 拒绝:`ChatView` 仍会拥有 Tool 名称分发与 Code Dispatch 编排,只是改变文件路径,没有改变责任。 + +**增加业务专属 Session projector 或 fold。** 拒绝:普通 Tool view 消费 Runtime 已重建的标准 call block。第二套 registry 会为 call identity 与历史 replay 建立两个权威。只有会改变日志拓扑或生命周期的能力才应获得 Runtime 级扩展。 + +**让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。root/child 编排归 `ui-tool`,且当前 wire/runtime 形态只支持一层 Code Dispatch child。 + +**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转预期的 feature 依赖方向,并把 Tool 展示变成必选能力。声明式 slot 能保留生命周期所有权、fallback 行为与独立插件装载。 + +## Consequences + +`ui-conversation` 不再依赖 Tool 名称对应的业务展示,同时保留 ChatFlow、selection 与宿主交互责任。root call 与 subcall 不会漂移到不同分发路径,业务包无需修改 Session 即可拥有原子 Tool 展示。代价是新增一个 Client package 与两个跨包 slot seam;`ui-tool` 也明确依赖 conversation 声明的席位与 locale namespace。因此组装后的 Web bundle 会挂载 `ui-tool`;省略该插件时,chat Tool 席位为空,details 席位则保留 raw-result fallback,且 Session 重建不受影响。 From cd64cc7ecdd955ee77bc1c67cf6a36bcd1043770 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:19:40 +0800 Subject: [PATCH 087/100] docs(notes): update Tool UI implementation references --- .../2026-08-05-context-meter-blind-to-compaction.i18n.yaml | 4 ++-- .../bug-fix/2026-08-05-context-meter-blind-to-compaction.md | 2 +- .../2026-08-05-context-meter-blind-to-compaction.zh.md | 2 +- .../feature/2026-07-23-web-todo-display.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-23-web-todo-display.md | 2 +- .../implemented/feature/2026-07-23-web-todo-display.zh.md | 2 +- .../2026-07-26-code-mode-chat-subcall-rows.i18n.yaml | 4 ++-- .../feature/2026-07-26-code-mode-chat-subcall-rows.md | 2 +- .../feature/2026-07-26-code-mode-chat-subcall-rows.zh.md | 2 +- .../feature/2026-07-28-web-terminal-card.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-28-web-terminal-card.md | 4 ++-- .../implemented/feature/2026-07-28-web-terminal-card.zh.md | 4 ++-- .../2026-07-29-ask-question-web-presentation.i18n.yaml | 4 ++-- .../feature/2026-07-29-ask-question-web-presentation.md | 2 +- .../feature/2026-07-29-ask-question-web-presentation.zh.md | 2 +- .../implemented/feature/2026-07-30-web-diff-card.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-07-30-web-diff-card.md | 4 ++-- .../implemented/feature/2026-07-30-web-diff-card.zh.md | 4 ++-- .../feature/2026-07-30-web-read-card-frontend.i18n.yaml | 4 ++-- .../feature/2026-07-30-web-read-card-frontend.md | 4 ++-- .../feature/2026-07-30-web-read-card-frontend.zh.md | 4 ++-- .../feature/2026-07-30-web-result-card-frontend.i18n.yaml | 4 ++-- .../feature/2026-07-30-web-result-card-frontend.md | 4 ++-- .../feature/2026-07-30-web-result-card-frontend.zh.md | 4 ++-- .../feature/2026-07-30-web-search-card.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-07-30-web-search-card.md | 6 +++--- .../implemented/feature/2026-07-30-web-search-card.zh.md | 6 +++--- ...-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml | 4 ++-- .../2026-07-30-web-tool-row-unified-expand-and-inspect.md | 4 ++-- ...2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md | 4 ++-- .../feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml | 4 ++-- .../feature/2026-08-02-web-thinking-tail-scroll.md | 2 +- .../feature/2026-08-02-web-thinking-tail-scroll.zh.md | 2 +- .../feature/2026-08-03-web-search-source-scroll.i18n.yaml | 4 ++-- .../feature/2026-08-03-web-search-source-scroll.md | 2 +- .../feature/2026-08-03-web-search-source-scroll.zh.md | 2 +- .../feature/2026-08-06-web-skill-tool-row.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-06-web-skill-tool-row.md | 2 +- .../implemented/feature/2026-08-06-web-skill-tool-row.zh.md | 2 +- 39 files changed, 66 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml index 3d9fef5b34..dfaaea4ab7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md -2026-08-05-context-meter-blind-to-compaction.md: ab39ae4e109f238960fd60de5e5b61075344f525 -2026-08-05-context-meter-blind-to-compaction.zh.md: c93aa509530e48acf906b18ba85bab4c7d355d69 +2026-08-05-context-meter-blind-to-compaction.md: 8f4845c3c9bf52c5c3a2d39dee2ff25bda30c7bd +2026-08-05-context-meter-blind-to-compaction.zh.md: 94ada0a9118db63876d5805ef5a8197d9c764102 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md index ab39ae4e10..8f4845c3c9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md @@ -43,4 +43,4 @@ The panel's composition rows still do not sum to the header, and now for one cle ## Testing -`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.spec.tsx` pins the ring reading the projected figure, and `chat-stats-bash-sample.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.spec.tsx` pins the ring reading the projected figure, and `chat-stats.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md index c93aa50953..94ada0a911 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md @@ -43,4 +43,4 @@ AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messag ## 测试 -`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了样本在表层增长与一次压缩上的推进(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats-bash-sample.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了样本在表层增长与一次压缩上的推进(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index cc872a60c3..f90cfe79db 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: 9e6e4914cd24d1db9271baa3d3fb6fdc56a9ac65 -2026-07-23-web-todo-display.zh.md: 5a5ac554b37c255a7d8c1ce821ebeb1b3f9f091f +2026-07-23-web-todo-display.md: bb66ef8512badea090b3b22030eef3f43f3b1119 +2026-07-23-web-todo-display.zh.md: 7270874d1e96bb0a553d57ba24b3bc2bb38a7a71 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 9e6e4914cd..bb66ef8512 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -22,7 +22,7 @@ The panel mounts through the `conversation.input.dock` slot (a plain registrant ### TodoRow: the per-call row through the keyed toolview slot -The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot through `ctx.slots.inject`, the same declaration-lifetime posture as the bash sample but a product registration. The summary derives from call args (`N/M done · first active item`, with a `+` count of the other active ones in `ToolRow`'s non-shrinking `summarySuffix` slot); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. +The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `tool.call.toolview` slot through `ctx.slots.inject`, the same declaration-lifetime posture as the bash sample but a product registration. The summary derives from call args (`N/M done · first active item`, with a `+` count of the other active ones in `ToolRow`'s non-shrinking `summarySuffix` slot); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index 5a5ac554b3..7270874d1e 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -22,7 +22,7 @@ Status: implemented ### TodoRow:经 keyed toolview slot 的逐调用行 -专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.inject` 注册进 keyed 的 `conversation.chat.toolview` slot,遵循与 bash 样例相同的声明生命周期,但属产品级注册。摘要由调用 args 推导(`N/M done · first active item`,其余活跃项的 `+` 计数放在 `ToolRow` 的不收缩 `summarySuffix` 位里);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 +专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.inject` 注册进 keyed 的 `tool.call.toolview` slot,遵循与 bash 样例相同的声明生命周期,但属产品级注册。摘要由调用 args 推导(`N/M done · first active item`,其余活跃项的 `+` 计数放在 `ToolRow` 的不收缩 `summarySuffix` 位里);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml index 9086e891db..d550ad1fb6 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md -2026-07-26-code-mode-chat-subcall-rows.md: 7d666f0a9e4b8bdb9bd6f5d0d0984fee0c4b21e2 -2026-07-26-code-mode-chat-subcall-rows.zh.md: b6b7de6c34673a0a0fa801681d642067a324d4cd +2026-07-26-code-mode-chat-subcall-rows.md: dc09e8fda9dbfb156b6218dec67584ee7bfead75 +2026-07-26-code-mode-chat-subcall-rows.zh.md: d445ddac4704fb940550d27ef7400e79d859393b diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md index 7d666f0a9e..dc09e8fda9 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md @@ -15,7 +15,7 @@ With Code Mode enabled, the chat view showed one opaque `run_code` row: raw prog **Sub-calls are `ToolResultNode`s indexed off the surface flow, rendered through the same keyed slot as native rows, nested always-visible under their parent.** - **Data layer**: `Session.applyEventSideEffects` folds each in-window `tool/code-dispatch` into `ConversationSnapshot.codeDispatches: ReadonlyMap`, where `CodeSubCall` IS `ToolResultNode` (the sub-call id as `callId`, the logged args JSON-stringified into `call.argsRaw`, the full logged `content`/`isError`). Live mux frames and history replay build the identical index (`rebuildDerivedFromWindow` clears and re-derives; copy-on-write per-parent arrays keep snapshot references memo-stable). Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The event is narrowed structurally at the wire-consumer boundary (dsh-tools' host types cannot enter the client program — the host/client `Context` merges collide), the same posture as every cross-wire payload. -- **Render layer**: `ChatView`'s `CallRow` renders the parent, then — for parents present in the index — a `[data-subcalls]` nest of `SubCallRow`s, each dispatching through the SAME `'conversation.chat.toolview'` keyed hole with `entryKey = sub-tool name` and the same `GenericToolCard` fallback. Identity with native rows holds by construction: a keyed registration (e.g. the bash sample) takes over sub-rows exactly as it takes over top-level rows, with zero registration changes. Running parents (`runningCalls`) nest their so-far dispatches the same way, so sub-rows stream in live during the run (PR1 logs each dispatch as it completes). +- **Render layer**: `ChatView` passes each parent and its indexed children through the whole-Tool `'conversation.chat.tool'` seat. ui-tool's `ToolCallTree` renders the parent followed by a `[data-subcalls]` nest, and every atomic call dispatches through the same `'tool.call.toolview'` keyed slot with `entryKey = Tool name` and the same `GenericToolCard` fallback. A keyed registration therefore takes over child and top-level calls without registration changes. Running parents (`runningCalls`) receive their accumulated dispatches through the same owner payload, so child rows stream in during the run. - **`run_code` presentation**: a new `code` row variant (classifier `run_code → code`, `Code` title, `IconCodeOutline16`) summarizes with the model-authored `description` and expands to the program itself (monospace on the markdown code-block fill) rather than the args JSON envelope. - **Details panel**: `materialFor` falls through nodes → runningCalls → the dispatch index, so a selected sub-callId resolves to full args and complete output through the identical rendering path as a native settled call. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md index b6b7de6c34..d445ddac47 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md @@ -15,7 +15,7 @@ Status: implemented **子调用在界面流之外单独索引为 `ToolResultNode`,经由与原生行相同的 keyed slot 渲染,以始终可见的方式嵌套在父行之下。** - **数据层**:`Session.applyEventSideEffects` 把窗口内的每条 `tool/code-dispatch` 折入 `ConversationSnapshot.codeDispatches: ReadonlyMap`,其中 `CodeSubCall` 本身就是 `ToolResultNode`(子调用 id 充当 `callId`,已记录的参数经 JSON 字符串化写入 `call.argsRaw`,完整记录的 `content`/`isError` 原样携带)。实时多路复用帧与历史回放构建出同一份索引(`rebuildDerivedFromWindow` 先清空再重新推导;逐父级的写时复制(copy-on-write)数组保持快照引用稳定,便于 memo 化)。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。该事件在 wire 消费方边界作结构性收窄(dsh-tools 的宿主类型无法进入客户端程序——宿主端/客户端两侧的 `Context` 声明合并会冲突),姿态与所有跨 wire 载荷一致。 -- **渲染层**:`ChatView` 的 `CallRow` 先渲染父行,随后对索引中出现的父级渲染一组 `[data-subcalls]` 嵌套的 `SubCallRow`,每一行都经由同一个 `'conversation.chat.toolview'` keyed slot、以 `entryKey = sub-tool name` 分发,并共用同一个 `GenericToolCard` 后备组件。与原生行的同一性由构造保证:一个 keyed 注册(例如 bash 样例)接管子行与接管顶层行的方式完全相同,注册本身零改动。运行中的父调用(`runningCalls`)也以同样的方式嵌套目前已产生的分发,因此子行在运行期间实时流入(PR1 在每次分发完成时即记录该分发)。 +- **渲染层**:`ChatView` 通过整体 Tool seat `'conversation.chat.tool'` 传递每个 parent 及其已索引的 child。ui-tool 的 `ToolCallTree` 先渲染 parent,再渲染一组 `[data-subcalls]` 嵌套;每个原子调用都通过同一个 `'tool.call.toolview'` keyed slot,以 Tool 名称作为 `entryKey`,并共用 `GenericToolCard` fallback。一个 keyed 注册因此无需变化即可同时接管 child 与顶层调用。运行中的 parent(`runningCalls`)通过同一 owner 载荷接收已累积的 dispatch,使 child 行在运行期间实时流入。 - **`run_code` 的呈现**:新增一种 `code` 行变体(分类器映射 `run_code → code`、标题 `Code`、图标 `IconCodeOutline16`),以模型撰写的 `description` 作摘要,展开后显示程序本身(在 markdown 代码块的填充底色上以等宽字体呈现),而非参数的 JSON 封装。 - **详情面板**:`materialFor` 按 nodes → runningCalls → 分发索引的顺序逐级回落,因此被选中的子调用 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 5e079bc180..0a7fb2b3ef 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 0e5f3e2157ebfc4e71aead26c15b6ee91958a5d5 -2026-07-28-web-terminal-card.zh.md: 1285d3fbb46ebd32ff163feac632cd487e8a04f1 +2026-07-28-web-terminal-card.md: 0493db4b86ce869ce5e699359e66dda70e526116 +2026-07-28-web-terminal-card.zh.md: 10137f83a25860a42e80a7b807a8aed68e86ce18 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 0e5f3e2157..0493db4b86 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -12,7 +12,7 @@ The Web client ignored it. `packages/client/ui-conversation/src/client/contract/ ## Decision -`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means. +`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-tool/src/client/models/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means. The component's contract: @@ -57,7 +57,7 @@ Inline rendering is licensed for the terminal intent alone. A future intent that `packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. -`packages/client/ui-conversation/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-conversation/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. +`packages/client/ui-tool/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-tool/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. `apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 65 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes, and turn 60's command was made two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`). That terminal turn is ordered BEFORE the todo turn on purpose: the standing plan retires at the next `turn/start`, so appending it after would have emptied the dock's plan strip and taken the todo surfaces' own coverage with it; that turn also carries what turn 60's two prompt rows cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit authored beside the sample. The sample's body deliberately carries NO `[exit code: N]` line: the real bash presenter consumes that marker out of the body precisely because the card shows the exit as its own pill, so leaving it in would pin a frame showing the exit twice — one the product path cannot produce. diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index 1285d3fbb4..10137f83a2 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -12,7 +12,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c ## Decision -`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。 +`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-tool/src/client/models/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。 该组件的契约: @@ -57,7 +57,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c `packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 -`packages/client/ui-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 +`packages/client/ui-tool/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-tool/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 `apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮有意排在 todo 轮**之前**:站立计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮两个提示行无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及在样本旁另行标注的非零退出码。样本正文有意**不含** `[exit code: N]` 行:真实的 bash presenter 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml index 161e7b7fe7..35c6e070a6 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md -2026-07-29-ask-question-web-presentation.md: 90eeb3cdcc1a851b7d5e184c0f31cbccd82cbf55 -2026-07-29-ask-question-web-presentation.zh.md: d1d18c030fd6cd9fc7832f82c19b49e4e8e04d30 +2026-07-29-ask-question-web-presentation.md: 11471bba08b723620b83af0e14e64a759d11c520 +2026-07-29-ask-question-web-presentation.zh.md: 50f0dffa1a1f9cbd29b676cc1f21ba0298f6bf23 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md index 90eeb3cdcc..11471bba08 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md @@ -12,7 +12,7 @@ Separately, the composer visuals had drifted from the current design: an expand- ## Decision -A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `conversation.chat.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrows to `PendingWait<'approval'>` and `ChatView` filters the pending list to approval waits, so the placeholder card now exists only for the approval takeover still on the roadmap. +A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `tool.call.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrows to `PendingWait<'approval'>` and `ChatView` filters the pending list to approval waits, so the placeholder card now exists only for the approval takeover still on the roadmap. The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim. diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md index d1d18c030f..50f0dffa1a 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md @@ -12,7 +12,7 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, ## 决定 -一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `conversation.chat.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 收窄为 `PendingWait<'approval'>`,`ChatView` 将待处理列表过滤为仅审批等待,占位卡片从此只服务于仍在路线图上的审批接管。 +一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `tool.call.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 收窄为 `PendingWait<'approval'>`,`ChatView` 将待处理列表过滤为仅审批等待,占位卡片从此只服务于仍在路线图上的审批接管。 输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index 388a8de9cd..ef2b3e55a0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md -2026-07-30-web-diff-card.md: eb43e09d6173ca2270df97cecaab6da36c70a679 -2026-07-30-web-diff-card.zh.md: 669cd49abc8eba8637705cd7c9f331cc51607755 +2026-07-30-web-diff-card.md: c8aed2aa59d82520a2a52523edb9b66d0bb34bf0 +2026-07-30-web-diff-card.zh.md: c2127844165e5f5c162eb3707fa5c86aa3a536c0 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index eb43e09d61..c8aed2aa59 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -14,7 +14,7 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` ## Decision -`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change. +`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-tool/src/client/models/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change. The component shares the TUI's single-column framing, line-terminator rule, and distinct-path file count. Line classification differs: Web renders the complete old and new sides, while the TUI derives neutral context and exact changed rows when its bounded comparison completes and labels its whole-side fallback approximate. @@ -46,7 +46,7 @@ The multi-file arm of `DiffBlock` (one card, several path headers) has no produc `packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%. -`packages/client/ui-conversation/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section. +`packages/client/ui-tool/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section. The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index 669cd49abc..c212784416 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -14,7 +14,7 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行 ## Decision -`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。 +`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-tool/src/client/models/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。 该组件与 TUI 共用单栏框架、行终止符规则和去重路径计数。两者的行分类不同:Web 渲染完整的变更前后两侧,而 TUI 会在有界比较完成时派生中性上下文和精确变更行,并把整侧回退标记为近似结果。 @@ -46,7 +46,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX `packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。 -`packages/client/ui-conversation/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。 +`packages/client/ui-tool/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。 fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身契约它不带 diff 行为断言,那由接线套件负责。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml index 388eb85ef8..71ea2aabaf 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md -2026-07-30-web-read-card-frontend.md: 06559d70c655b86a6aa70c8a9e948f4f21a1f522 -2026-07-30-web-read-card-frontend.zh.md: 92fb724658d4c23b915f61efc3b482fdf1ce7c7b +2026-07-30-web-read-card-frontend.md: 10ed9d3eaa54c2440cf3fbd00a7e44b76c1acfe9 +2026-07-30-web-read-card-frontend.zh.md: 931316135470b7e7257f7f9d016b24fe741fdbf5 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md index 06559d70c6..10ed9d3eaa 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md @@ -10,7 +10,7 @@ The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent car ## Decision -`ReadBlock` is a `ui-primitives` component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. `ui-conversation/src/client/contract/read-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so the two sites cannot disagree. +`ReadBlock` is a `ui-primitives` component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. `ui-tool/src/client/models/read-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so the two sites cannot disagree. **A new `ReadBlock` primitive, not an extension of `CodeBlock`.** `CodeBlock` already does shiki highlighting with a language banner and a copy control, but a read view needs a per-line gutter carrying each line's own file number, which `CodeBlock` renders as a single `
` tree with no per-line structure. Extending `CodeBlock` with an optional gutter would push a read-specific concern (windowed line numbers, a "showing N of M" note, a height cap) onto every markdown fence and every `run_code` body that shares that component. Instead `ReadBlock` reuses the part that is genuinely shared: the shiki grammar singleton in `markdown/highlight.ts`. A new `highlightLines(code, lang)` there tokenizes into shiki's own per-line token arrays (`codeToTokens`) rather than the single-`
` HTML `highlightToHtml` produces, so the block can place one gutter number per line and still color the content through the same `--shiki-*` custom properties on the same grammar allowlist. The height cap and its head/tail expand arithmetic are copied from `TerminalBlock` (`ceil(max/2)` head plus the remaining tail), so a long read and a long command output collapse at the same place. The copy control writes the window's raw text (the lines joined by newlines), never the gutter numbers or the banner.
 
@@ -42,7 +42,7 @@ A read row in the Web chat now carries the file content resident, a deliberate d
 
 `packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs.
 
-`packages/client/ui-conversation/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so it is written against no gate pressure.
+`packages/client/ui-tool/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so it is written against no gate pressure.
 
 The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`. The turn 64 `run_code` sample's nested read sub-dispatches do not exercise the render-site fallback read card: `session.ts` folds them with `resultView: null`, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by `read-card.spec.tsx`'s `web_fetch` case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`.
 
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
index 92fb724658..9313161354 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
@@ -10,7 +10,7 @@ Status: implemented
 
 ## Decision
 
-`ReadBlock` 是一个 `ui-primitives` 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。`ui-conversation/src/client/contract/read-card-model.ts` 是把快照的 `resultView` 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。
+`ReadBlock` 是一个 `ui-primitives` 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。`ui-tool/src/client/models/read-card-model.ts` 是把快照的 `resultView` 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。
 
 **新建一个 `ReadBlock` primitive,而不是扩展 `CodeBlock`。** `CodeBlock` 已经带语言横幅和复制控件做 shiki 高亮,但读取视图需要一个每行带该行自身文件行号的行号栏,而 `CodeBlock` 把内容渲染为单个 `
` 树、没有逐行结构。给 `CodeBlock` 加一个可选行号栏会把读取专属的关切(窗口行号、"显示 N / M"提示、高度上限)强加给共享该组件的每个 markdown 代码围栏和每个 `run_code` 程序体。`ReadBlock` 转而复用真正共享的部分:`markdown/highlight.ts` 里的 shiki 语法单例。那里新增的 `highlightLines(code, lang)` 把代码切成 shiki 自己的逐行 token 数组(`codeToTokens`),而不是 `highlightToHtml` 产出的单 `
` HTML,于是该 block 能每行放一个行号、同时用同一套 `--shiki-*` 自定义属性、同一份语法白名单给内容上色。高度上限及其头/尾展开算法照抄自 `TerminalBlock`(`ceil(max/2)` 行头部加剩余的尾部),因此长读取和长命令输出在同一处折叠。复制控件写入窗口的原始文本(各行以换行拼接),绝不含行号栏或横幅。
 
@@ -42,7 +42,7 @@ Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行
 
 `packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx`、`highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。
 
-`packages/client/ui-conversation/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-conversation/src/*`),因此不承受门槛压力。
+`packages/client/ui-tool/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-tool/src/*`),因此不承受门槛压力。
 
 fixture(`packages/client/connection/src/client/fixture.ts`)增加 turn 66,一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`。turn 64 的 `run_code` 样例中的嵌套读取子派发并不驱动渲染点回退读取卡片:`session.ts` 把它们折叠为 `resultView: null`,因此它们只覆盖回退行的通用行形状,而非回退行内的读取卡片;回退行读取卡片由 `read-card.spec.tsx` 的 `web_fetch` 用例钉住。turn 66 排在 todo turn(现为 67)之前,与终端样例同因:常驻计划在下一次 `turn/start` 退场。
 
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
index aab320ac99..ded72420fc 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
-2026-07-30-web-result-card-frontend.md: 7457f30f71e811960ecadeb49caedef276682505
-2026-07-30-web-result-card-frontend.zh.md: 5fa53c4ecbeda40bd18a3c4e59ec75bd4358662d
+2026-07-30-web-result-card-frontend.md: 1d58710dbce3ed1aa9337e1841940195f71db40a
+2026-07-30-web-result-card-frontend.zh.md: 8705aa05557c1fdf642f498939bc2ceddde91f1f
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
index 7457f30f71..1d58710dbc 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
@@ -10,7 +10,7 @@ The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web
 
 ## Decision
 
-`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
+`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-tool/src/client/models/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
 
 One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant.
 
@@ -38,7 +38,7 @@ A separate later PR unifies the whole-row collapse/expand interaction and will f
 
 `packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the full source list rendering inside one scroll container with no expand control and `
  • ` numbering every source contiguously from 1. -`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it. +`packages/client/ui-tool/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so a coverage run measures none of it. The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md index 5fa53c4ecb..8705aa0555 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。 +`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-tool/src/client/models/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。 一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。 @@ -38,7 +38,7 @@ Status: implemented `packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及完整 source 列表渲染在单个滚动容器内、无展开控件、`
  • ` 从 1 起为每条 source 连续编号。 -`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。 +`packages/client/ui-tool/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-tool/src/*`),因此覆盖率运行不度量它。 fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml index 2d2b338b6f..1578f0c73e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-search-card.md -2026-07-30-web-search-card.md: 4c7ae6c8c658f4f10f0667b12853cb2e70df15b1 -2026-07-30-web-search-card.zh.md: b129d411b9b402b18d6b4ec94dad544effd3bb8c +2026-07-30-web-search-card.md: a350756a22d2ba5da8a0ff5d3a4cb3f257ac566b +2026-07-30-web-search-card.zh.md: d6ec2a08eb02a92a50d4742c3d87c22f42c095d4 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md index 4c7ae6c8c6..a350756a22 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -12,7 +12,7 @@ This is the follow-up the search render card note names: that PR was the backend ## Decision -`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation. +`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-tool/src/client/models/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation. The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card. @@ -34,7 +34,7 @@ Geometry, radius, and fonts mirror `CodeBlock` and `TerminalBlock`, so a search Three sites consume the derivation, mirroring the terminal card's placement exactly: -- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. A capped result's recovery footer sits below the card. Because the keyed row owns this render slot, a settled call with no search card — an errored search (grep/glob emit no result view on error), a successful nested `run_code` sub-dispatch (the backend computes no `presentationMeta`, so `resultView` is null), or a legacy generic result — would otherwise show only its summary with its content lost; the row surfaces that model-facing text as a fallback body, keyed on `search === null && settled` rather than on the error state alone. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.) +- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `tool.call.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. A capped result's recovery footer sits below the card. Because the keyed row owns this render slot, a settled call with no search card — an errored search (grep/glob emit no result view on error), a successful nested `run_code` sub-dispatch (the backend computes no `presentationMeta`, so `resultView` is null), or a legacy generic result — would otherwise show only its summary with its content lost; the row surfaces that model-facing text as a fallback body, keyed on `search === null && settled` rather than on the error state alone. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.) - **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card, with the recovery footer, behind the row's expand toggle. - **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, with the recovery footer below it, keeping the JSON Input section. @@ -56,7 +56,7 @@ Three sites consume the derivation, mirroring the terminal card's placement exac `packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. -`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. +`packages/client/ui-tool/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-tool/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md index b129d411b9..d6ec2a08eb 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -12,7 +12,7 @@ Status: implemented ## Decision -`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`shape` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `shape` 和分组/扁平内容与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `shape` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(不是 `kind` —— 后端把 `kind` 留给 call view 的选图标签);`SearchBlock` 自身的 prop 仍是 `kind`,由本推导从 `shape` 映射得到。 +`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-tool/src/client/models/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`shape` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `shape` 和分组/扁平内容与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `shape` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(不是 `kind` —— 后端把 `kind` 留给 call view 的选图标签);`SearchBlock` 自身的 prop 仍是 `kind`,由本推导从 `shape` 映射得到。 与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。 @@ -34,7 +34,7 @@ Status: implemented 三个渲染点消费该推导,与终端卡片的落位完全一致: -- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。被截断结果的恢复脚注画在卡片下方。因为 keyed 行占据了这个渲染槽,一个没有搜索卡片的已结算调用 —— 出错的搜索(grep/glob 出错时不产出结果视图)、成功的嵌套 `run_code` 子派发(后端不为其计算 `presentationMeta`,故 `resultView` 为 null)、或旧日志的 generic 结果 —— 否则只会显示摘要而丢失内容;该行把这段面向模型的文本作为 fallback body 暴露出来,判据是 `search === null && 已结算`,而非仅凭错误状态。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。) +- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `tool.call.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。被截断结果的恢复脚注画在卡片下方。因为 keyed 行占据了这个渲染槽,一个没有搜索卡片的已结算调用 —— 出错的搜索(grep/glob 出错时不产出结果视图)、成功的嵌套 `run_code` 子派发(后端不为其计算 `presentationMeta`,故 `resultView` 为 null)、或旧日志的 generic 结果 —— 否则只会显示摘要而丢失内容;该行把这段面向模型的文本作为 fallback body 暴露出来,判据是 `search === null && 已结算`,而非仅凭错误状态。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。) - **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片,并带恢复脚注。 - **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,恢复脚注画在其下方,保留 JSON Input 段。 @@ -56,7 +56,7 @@ Status: implemented `packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 -`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库契约要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按契约只测启动)无法捕获它。 +`packages/client/ui-tool/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-tool/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库契约要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按契约只测启动)无法捕获它。 ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml index a2710c73f9..255dd08832 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md -2026-07-30-web-tool-row-unified-expand-and-inspect.md: 98f1595564f0bd0d22f1ca4318b4c7fe15c6900d -2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: 8f00349a975f777cc4d556ade3a9abe9676b8848 +2026-07-30-web-tool-row-unified-expand-and-inspect.md: 4fcf1e9a567b73cd04f1bbed170c391aaedd9e73 +2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: 92d70f5aa6bd09e6f9e54015dafc38d8d3690a9e diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md index 98f1595564..4fcf1e9a56 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md @@ -16,7 +16,7 @@ The chat view's tool rows had drifted into per-surface interaction dialects: Too - The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card. - `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`. - TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row. -- Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field. +- Inspect: `ToolCallOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field. - Scroll preservation: on every non-bottom scroll, the chat view saves `{ anchorKey, anchorTop, scrollTop }` into an apply-scope per-session map exposed as `chatScroll`; a remount first uses `scrollTop` to reach the approximate window, then corrects by the stable node/call anchor's rectangle delta so width reflow keeps the same reading row in place. Every pinned path, including Back to bottom, clears the entry synchronously before a tab or session switch. The map remains deliberately unpersisted — a fresh page load keeps the open-jump-to-bottom default. ## Alternatives considered @@ -31,4 +31,4 @@ The chat view's tool rows had drifted into per-surface interaction dialects: Too ## Consequences -Any registered toolview gets input AND output inspection in place, with the details panel and trajectory remaining the deep-dive surfaces. The unified interaction is contract-visible (`ToolRowProps.output/errorSummary/inspect`), so third-party rows opt in by passing model fields through. The bash sample intentionally re-replicates the new CSS (registrant posture), so future interaction changes still touch it by hand. `--dsw-font-markdown-code-block-small` (12/18) is a hand-added token pending a design-platform export. The web-cordis `distIndex` fix (plain concatenation, not URL.pathname) unblocks preview boots from a cwd with spaces. +Built-in ui-tool views get input and output inspection in place, with the details panel and trajectory remaining the deep-dive surfaces. The shared `ToolRow` interaction is internal to ui-tool; an external atomic view receives `ToolCallViewProps` and may expose the supplied `inspect` callback through its own chrome. The bash view keeps its separate CSS, so future interaction changes still touch it explicitly. `--dsw-font-markdown-code-block-small` (12/18) is a hand-added token pending a design-platform export. The web-cordis `distIndex` fix (plain concatenation, not URL.pathname) unblocks preview boots from a cwd with spaces. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md index 8f00349a97..92d70f5aa6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md @@ -16,7 +16,7 @@ - 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。 - `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`。 - TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。 -- Inspect:`ToolRowOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。 +- Inspect:`ToolCallOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。 - 滚动保留:每次非贴底滚动时,聊天视图把 `{ anchorKey, anchorTop, scrollTop }` 保存到 apply 作用域的按会话 Map,并经注入 props 的 `chatScroll` 暴露;重挂载时先用 `scrollTop` 到达近似窗口,再按稳定 node/call 锚点的矩形差值校正,因此宽度重排后仍把同一阅读行保持在原位。包括「回到底部」在内的每条贴底路径都会在切换 tab 或会话前同步清除该项。Map 仍刻意不持久化——新页面加载保持打开即贴底的默认行为。 ## 曾考虑的替代方案 @@ -31,4 +31,4 @@ ## 后果 -任何已注册 toolview 都能就地查看输入与输出,详情面板和 trajectory 仍是深查表面。统一交互契约可见(`ToolRowProps.output/errorSummary/inspect`),第三方行透传模型字段即可接入。bash 示例有意重新复刻新 CSS(注册方姿态),未来交互变更仍需手动同步它。`--dsw-font-markdown-code-block-small`(12/18)是手工补充的 token,待设计平台导出后替换。web-cordis 的 `distIndex` 修复(纯拼接而非 URL.pathname)解除了含空格 cwd 下预览无法启动的问题。 +ui-tool 内置视图都能就地检查输入与输出,详情面板和 trajectory 仍是深查界面。共享 `ToolRow` 交互是 ui-tool 内部实现;外部原子视图接收 `ToolCallViewProps`,可以通过自己的 chrome 暴露其中的 `inspect` 回调。bash 视图保留独立 CSS,因此未来交互变化仍需显式同步。`--dsw-font-markdown-code-block-small`(12/18)是手工补充的 token,待设计平台导出后替换。web-cordis 的 `distIndex` 修复(纯拼接而非 URL.pathname)解除了含空格 cwd 下预览无法启动的问题。 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml index f4f0ef3891..c12de68dc8 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md -2026-08-02-web-thinking-tail-scroll.md: c45840731153627b4ce460ee140257ba33d2c007 -2026-08-02-web-thinking-tail-scroll.zh.md: b8d0444d62294e123bec1d26cb4c07538bbf966f +2026-08-02-web-thinking-tail-scroll.md: 18e94b2e0075bf7099b9b48177de2e274896942a +2026-08-02-web-thinking-tail-scroll.zh.md: 9b1428af7d3ab3c25509696619de75adc1cd7b7f diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md index c458407311..18e94b2e00 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md @@ -28,4 +28,4 @@ The collapsed row now communicates provider cadence through content motion as we ## Testing -`packages/client/ui-conversation/tests/chat-tool-row.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. +`packages/client/ui-conversation/tests/reasoning-row.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md index b8d0444d62..9b1428af7d 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md @@ -28,4 +28,4 @@ Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠 ## 测试 -`packages/client/ui-conversation/tests/chat-tool-row.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要契约仍然稳定。 +`packages/client/ui-conversation/tests/reasoning-row.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要契约仍然稳定。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml index 80f7ed4ab4..2ba0af23c4 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md -2026-08-03-web-search-source-scroll.md: c11bb2317b6ae6cad8017a4b76cb0b9ccebd6fc0 -2026-08-03-web-search-source-scroll.zh.md: add012216589b33cf244d8a14053a5b6d60631a6 +2026-08-03-web-search-source-scroll.md: 3215302b2f6c7a4e6d6cd7440a0c7f5048b72a80 +2026-08-03-web-search-source-scroll.zh.md: cf75a5e34632872d09a13e1667ecac5f01428e18 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md index c11bb2317b..3215302b2f 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md @@ -36,7 +36,7 @@ Every source the tool returned is always in the DOM, so no source the view carri ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `
  • ` with no `[aria-expanded]` and no ` - ) : ( - - {leading} - - )} - {title} - {(keepContentWhenOpen || !open) && collapsedContent} -
  • - {open && children} -
    - ) -} diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 61c677ea98..bd4add4234 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -20,7 +20,7 @@ import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { - CodeBlock, DiffBlock, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, + CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -29,7 +29,6 @@ import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-mod import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts' import { terminalBlockLabels, type TerminalCardModel } from '../models/terminal-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts' -import { DisclosureRow } from './DisclosureRow.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts index 8a0c887990..e0609191b7 100644 --- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -8,9 +8,10 @@ * are derived once. * @module */ +import { resolveWorkspacePath } from '@deepseek-ai/dsh-client-runtime/client' import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts' +import type { ToolCallBlock } from './tool-call-model.ts' /** * Build the TerminalBlock display copy from the conversation locale seat — @@ -88,7 +89,7 @@ export function terminalFailed(model: TerminalCardModel): boolean { function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined { if (viewCwd === undefined || viewCwd === '') return sessionCwd if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd) - return normalizeSegments(resolveToolPath(sessionCwd, viewCwd)) + return normalizeSegments(resolveWorkspacePath(sessionCwd, viewCwd)) } /** diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index 151ef3b45f..f201f4aac4 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -172,21 +172,6 @@ function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | unde return picked === undefined ? undefined : firstLine(picked) } -/** - * Resolve a tool-arg path against the session cwd for host.openPath. - * Absolute POSIX/Windows paths pass through; relative paths join under cwd. - * @param cwd - session working directory (may be absent for ungrouped sessions). - * @param path - path as carried in tool args. - * @returns a host-facing path string. - */ -export function resolveToolPath(cwd: string | undefined, path: string): string { - if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path - if (cwd === undefined || cwd === '') return path - const base = cwd.replace(/[/\\]+$/, '') - const rel = path.replace(/^[/\\]+/, '') - return `${base}/${rel}` -} - function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { if (argsRaw === '') return null const parsed = parseArgs(argsRaw) diff --git a/packages/client/ui-tool/tests/tool-row.spec.tsx b/packages/client/ui-tool/tests/tool-row.spec.tsx index ab7e1c80f7..f53bcb11f7 100644 --- a/packages/client/ui-tool/tests/tool-row.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.spec.tsx @@ -5,7 +5,8 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-client-runtime/client' +import { classifyTool, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts' import { ToolRow } from '../src/client/tool/components/ToolRow.tsx' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { zh } from '../../ui-conversation/src/client/locales.ts' @@ -87,11 +88,11 @@ describe('tool-call-model', () => { expect(toolRowModel('bash', running()).filePath).toBeUndefined() }) - it('resolveToolPath joins relative paths under cwd and passes absolute through', () => { - expect(resolveToolPath('/w', 'src/a.ts')).toBe('/w/src/a.ts') - expect(resolveToolPath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') - expect(resolveToolPath(undefined, 'src/a.ts')).toBe('src/a.ts') - expect(resolveToolPath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') + it('resolveWorkspacePath joins relative paths under cwd and passes absolute through', () => { + expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') + expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') + expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts') + expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') }) it('displays workspace-rooted paths relative to the session cwd', () => { From 61f3982348d4117e7f99eb8ce2b20375a568c188 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:31:41 +0800 Subject: [PATCH 089/100] fix: gen docs --- docs/config-catalog.md | 1 + docs/module-graph.md | 26 +++++++++++++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 43b8bbc4d9..1f81974b21 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2566,6 +2566,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-slash` ([`packages/client/ui-slash/src/index.ts`](../packages/client/ui-slash/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-tool` ([`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) - `@deepseek-ai/dsh-command-compact` — requires `commands` · `compact` ([`packages/compact/command-compact/src/index.ts`](../packages/compact/command-compact/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 14cad0163a..22af77f9c5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -184,6 +184,7 @@ flowchart TD pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] pkg_client_ui_theme["client-ui-theme"] + pkg_client_ui_tool["client-ui-tool"] pkg_client_ui_trajectory["client-ui-trajectory"] pkg_client_ui_workspace["client-ui-workspace"] pkg_client_web["client-web"] @@ -891,14 +892,12 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_conversation - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants + pkg_client_ui_tool --> pkg_client_locale + pkg_client_ui_tool --> pkg_client_runtime + pkg_client_ui_tool --> pkg_client_ui_conversation + pkg_client_ui_tool --> pkg_client_ui_primitives + pkg_client_ui_tool --> pkg_client_ui_slots + pkg_client_ui_tool --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1057,6 +1056,14 @@ flowchart TD pkg_client_ui_plan --> pkg_client_ui_slots pkg_client_ui_plan --> pkg_invariants pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_client_ui_tool + pkg_client_ui_skill --> pkg_invariants pkg_client_ui_subagent --> pkg_client_locale pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation @@ -1310,7 +1317,7 @@ flowchart TD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1335,6 +1342,7 @@ flowchart TD | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | From 9c109253407eb1ff5e6e668f55b3c12061db912b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:33:51 +0800 Subject: [PATCH 090/100] fix(docs): align ui-tool package contracts --- .../feature/2026-07-28-web-terminal-card.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-28-web-terminal-card.md | 6 +++--- .../implemented/feature/2026-07-28-web-terminal-card.zh.md | 6 +++--- packages/client/ui-tool/README.i18n.yaml | 4 ++-- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- scripts/verify-package-readme-model-experience.ts | 1 + 7 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 0a7fb2b3ef..53dba67c61 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 0493db4b86ce869ce5e699359e66dda70e526116 -2026-07-28-web-terminal-card.zh.md: 10137f83a25860a42e80a7b807a8aed68e86ce18 +2026-07-28-web-terminal-card.md: 026b32fed2c79533abfadc58314e2844ff556414 +2026-07-28-web-terminal-card.zh.md: 4c74d880c5795cf85f32391b27e6ffd2c5074870 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 0493db4b86..026b32fed2 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -8,11 +8,11 @@ English | [中文](2026-07-28-web-terminal-card.zh.md) The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the former TUI rendered it as a `$`-prompt card with an exit line and a head/tail height cap. -The Web client ignored it. `packages/client/ui-conversation/src/client/contract/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `
    ` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
    +The Web client ignored it. `packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `
    ` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
     
     ## Decision
     
    -`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-tool/src/client/models/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means.
    +`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means.
     
     The component's contract:
     
    @@ -27,7 +27,7 @@ Geometry, radius, and fonts mirror `CodeBlock`, so a terminal card and a fenced
     
     ### Inline output in the chat row reverses a stated convention
     
    -`chat/ToolRow.tsx` and `contract/tool-call-model.ts` asserted "no inline output ever — full results live in the details panel". Showing the terminal block in the row reverses that, on the owner's explicit decision.
    +`packages/client/ui-tool/src/client/tool/components/ToolRow.tsx` and `packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` asserted "no inline output ever — full results live in the details panel". Showing the terminal block in the row reverses that, on the owner's explicit decision.
     
     The reason the reversal holds: for a shell command the output *is* the result the user is reading, so routing it exclusively to a panel makes the common case a two-step interaction. A bounded, height-capped, non-wrapping terminal block in the row is what makes a bash-heavy transcript readable in one pass. The old rule's actual concern was a row whose height was unbounded by the length of the output, and the height cap plus expand control is what keeps that from returning.
     
    diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
    index 10137f83a2..4c74d880c5 100644
    --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
    +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
    @@ -8,11 +8,11 @@ Status: implemented
     
     bash 工具的调用与结果都声明 `card: 'terminal'`([渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md)):调用视图携带命令、一段可选的模型撰写描述以及工作目录,结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 `ConversationSnapshot` 的 `callView`/`resultView` 上——原 TUI 曾把它渲染为带 `$` 提示符的卡片,附退出行与首尾高度上限。
     
    -Web client 却对它视而不见。`packages/client/ui-conversation/src/client/contract/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `
    `,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
    +Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `
    `,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
     
     ## Decision
     
    -`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-tool/src/client/models/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
    +`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
     
     该组件的契约:
     
    @@ -27,7 +27,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
     
     ### 聊天行内嵌输出推翻了一条既有约定
     
    -`chat/ToolRow.tsx` 与 `contract/tool-call-model.ts` 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
    +`packages/client/ui-tool/src/client/tool/components/ToolRow.tsx` 与 `packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
     
     这次推翻成立的理由:对 shell 命令而言,输出**就是**用户要读的结果,把它专门收进面板会让最常见的情形变成两步交互。行内一个有界、限高、不换行的终端块,正是让 bash 密集的 transcript 一遍读完的条件。旧规则真正担心的是行高不受输出长度约束,而高度上限加展开控件正是防止其复现的机制。
     
    diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml
    index 828cfa25ef..eca4d1cb7b 100644
    --- a/packages/client/ui-tool/README.i18n.yaml
    +++ b/packages/client/ui-tool/README.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
    -README.md: 381253f4eddaa57b89318dd23da3a049505fdd15
    -README.zh.md: ae539131198771bc1d0e280bbfaa76ec0ec60792
    +README.md: 69d6931d33788f2df593919d160c4bfeaef8d15c
    +README.zh.md: eb49465a743711bd48358bf3953a485f9ef037eb
    diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md
    index 381253f4ed..69d6931d33 100644
    --- a/packages/client/ui-tool/README.md
    +++ b/packages/client/ui-tool/README.md
    @@ -32,7 +32,7 @@ This package currently owns the generic fallback and the built-in bash/pwsh, rea
     
     ## Model Experience
     
    -None. This package renders already logged Tool calls and results and does not alter model requests, Tool execution, or session events.
    +None, as this package renders already logged Tool calls and results without altering model requests, Tool execution, or session events.
     
     #### KV Cache effect
     
    diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md
    index ae53913119..eb49465a74 100644
    --- a/packages/client/ui-tool/README.zh.md
    +++ b/packages/client/ui-tool/README.zh.md
    @@ -32,7 +32,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
     
     ## 模型体验
     
    -无。本包只渲染已经记录的 Tool 调用和结果,不改变模型请求、Tool 执行或 Session Event。
    +无,因为本包只渲染已经记录的 Tool 调用和结果,不改变模型请求、Tool 执行或 Session Event。
     
     #### KV Cache 影响
     
    diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
    index ddbd7b66f8..3ca0317cf4 100644
    --- a/scripts/verify-package-readme-model-experience.ts
    +++ b/scripts/verify-package-readme-model-experience.ts
    @@ -63,6 +63,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
       'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
       'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
       'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
    +  'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
       'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
       'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
       'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },
    
    From ab50037b93008ff2ac87b5c2df6ab8f6f632a9fa Mon Sep 17 00:00:00 2001
    From: imccyu <276526105+imccyu@users.noreply.github.com>
    Date: Sat, 8 Aug 2026 15:59:13 +0800
    Subject: [PATCH 091/100] fix(client): address tool presentation review
    
    ---
     .../2026-07-19-gui-web-client-architecture.i18n.yaml     | 4 ++--
     .../2026-07-19-gui-web-client-architecture.md            | 2 +-
     .../2026-07-19-gui-web-client-architecture.zh.md         | 2 +-
     ...26-08-08-client-tool-presentation-ownership.i18n.yaml | 4 ++--
     .../2026-08-08-client-tool-presentation-ownership.md     | 2 ++
     .../2026-08-08-client-tool-presentation-ownership.zh.md  | 2 ++
     .../feature/2026-07-30-web-diff-card.i18n.yaml           | 4 ++--
     .../implemented/feature/2026-07-30-web-diff-card.md      | 2 +-
     .../implemented/feature/2026-07-30-web-diff-card.zh.md   | 2 +-
     .../feature/2026-07-30-web-read-card-frontend.i18n.yaml  | 4 ++--
     .../feature/2026-07-30-web-read-card-frontend.md         | 2 +-
     .../feature/2026-07-30-web-read-card-frontend.zh.md      | 2 +-
     .../2026-07-30-web-result-card-frontend.i18n.yaml        | 4 ++--
     .../feature/2026-07-30-web-result-card-frontend.md       | 2 +-
     .../feature/2026-07-30-web-result-card-frontend.zh.md    | 2 +-
     .../feature/2026-07-30-web-search-card.i18n.yaml         | 4 ++--
     .../implemented/feature/2026-07-30-web-search-card.md    | 2 +-
     .../implemented/feature/2026-07-30-web-search-card.zh.md | 2 +-
     .../src/client/chat/AssistantMarkdown.tsx                | 2 +-
     .../src/client/chat/GenericCommandCard.tsx               | 2 ++
     .../ui-conversation/src/client/chat/ReasoningRow.tsx     | 6 +++++-
     .../src/client/chat/accessibility.module.css             | 8 ++++++++
     .../client/ui-conversation/src/client/contract/slots.ts  | 9 ++++++++-
     packages/client/ui-conversation/tests/chat-view.spec.tsx | 1 +
     .../client/ui-conversation/tests/reasoning-row.spec.tsx  | 2 ++
     packages/client/ui-tool/README.i18n.yaml                 | 4 ++--
     packages/client/ui-tool/README.md                        | 5 +++++
     packages/client/ui-tool/README.zh.md                     | 5 +++++
     packages/client/ui-tool/tests/ask-question-row.spec.tsx  | 2 +-
     packages/client/ui-tool/tests/coverage-tails.spec.tsx    | 2 +-
     packages/client/ui-tool/tests/diff-card.spec.tsx         | 6 +++---
     packages/client/ui-tool/tests/read-card.spec.tsx         | 6 +++---
     packages/client/ui-tool/tests/search-card.spec.tsx       | 6 +++---
     packages/client/ui-tool/tests/terminal-card.spec.tsx     | 6 +++---
     packages/client/ui-tool/tests/todo-row.spec.tsx          | 2 +-
     packages/client/ui-tool/tests/tool-call-tree.spec.tsx    | 2 +-
     packages/client/ui-tool/tests/tool-details-render.tsx    | 2 +-
     packages/client/ui-tool/tests/tool-row.spec.tsx          | 2 +-
     packages/client/ui-tool/tests/web-card.spec.tsx          | 6 +++---
     vitest.config.ts                                         | 1 +
     40 files changed, 87 insertions(+), 48 deletions(-)
     create mode 100644 packages/client/ui-conversation/src/client/chat/accessibility.module.css
    
    diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
    index 2dccd44aae..2357aa30f2 100644
    --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
    +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
    -2026-07-19-gui-web-client-architecture.md: 4dc4558ea245baa17646f92b9b5e4c9a45b6a419
    -2026-07-19-gui-web-client-architecture.zh.md: a306b5c82891840b96340f5464267cc9d861ef7e
    +2026-07-19-gui-web-client-architecture.md: cba029ea74e89b277f9079ebb3765deeeb105b47
    +2026-07-19-gui-web-client-architecture.zh.md: f700cc04fe1479d43957456c14b3abb4014d99c5
    diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
    index 4dc4558ea2..cba029ea74 100644
    --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
    +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
    @@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien
     
     A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
     
    -There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: ui-conversation places each ordered root call into the single `'conversation.chat.tool'` seat and passes the Runtime-projected Code Dispatch children without interpreting their Tool names; ui-tool renders that root/child shape and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and both roots and children dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components.
    +There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: ui-conversation places each ordered root call into the single `'conversation.chat.tool'` seat without interpreting Tool names or Code Dispatch topology; ui-tool selects `codeDispatches[rootCallId]` from the Runtime snapshot, renders that root/child shape, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and both roots and children dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components.
     
     **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
     
    diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
    index a306b5c828..f700cc04fe 100644
    --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
    +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
    @@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-
     
     服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
     
    -slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:ui-conversation 把每个已排序 root call 放进 single `'conversation.chat.tool'` seat,并透传 Runtime 已投影的 Code Dispatch child,不解释其 Tool 名称;ui-tool 渲染该 root/child 形状,并声明 keyed/session 的 `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与 child 都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。
    +slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:ui-conversation 把每个已排序 root call 放进 single `'conversation.chat.tool'` seat,不解释 Tool 名称或 Code Dispatch 拓扑;ui-tool 从 Runtime snapshot 选择 `codeDispatches[rootCallId]`、渲染 root/child 形状,并声明 keyed/session 的 `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与 child 都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。
     
     **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
     
    diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml
    index 47adf75d40..5097b9e8a9 100644
    --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml
    +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md
    -2026-08-08-client-tool-presentation-ownership.md: 4d06450a10c4198f20d7139aef815def9e7cb362
    -2026-08-08-client-tool-presentation-ownership.zh.md: 3d3bf3bc3204aca6871a82c7b7ae330b6381a3e4
    +2026-08-08-client-tool-presentation-ownership.md: e61c2030457cc9f0fda214e896b76afb37d0d2bc
    +2026-08-08-client-tool-presentation-ownership.zh.md: 5c56b8c17ef5ca6695f3b28f6b93218dade356c7
    diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md
    index 4d06450a10..e61c203045 100644
    --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md
    +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md
    @@ -66,6 +66,8 @@ A slot declaration also constrains render ownership. The conversation chat entry
     
     The whole seat's `ToolTreeOwnerProps` carries the root `callId`, `toolName`, `ToolCallBlock`, `selectedCallId`, session `cwd`, `openFile(path)`, and `inspectCall(callId)`. `ToolCallTree` converts either a root or child into the same `ToolCallOwnerProps` and narrows inspect to a callback for that call. The atomic owner carries no `ReactNode`, Cordis `Context`, Session service, or projector; a business view consumes only one standard call block and host actions.
     
    +The seat filler also preserves the conversation DOM contract on every root and child wrapper: `data-chat-anchor-key="call:"`, `data-chat-call-id`, and `data-selected="true"` on the selected call. `ChatView` consumes the anchor key to restore prepend/paging position; the Tool owner emits it because it alone composes child wrappers.
    +
     Business plugins use one registration shape:
     
     ```text
    diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md
    index 3d3bf3bc32..5c56b8c17e 100644
    --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md
    +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md
    @@ -66,6 +66,8 @@ slot 声明同时限定渲染所有权。conversation chat entry 通过 `childre
     
     整体席位的 `ToolTreeOwnerProps` 携带 root `callId`、`toolName`、`ToolCallBlock`、`selectedCallId`、session `cwd`、`openFile(path)` 与 `inspectCall(callId)`。`ToolCallTree` 把 root 或 child 转成相同的 `ToolCallOwnerProps`,并把 inspect 收窄成当前 call 的回调。原子 owner 不携带 `ReactNode`、Cordis `Context`、Session service 或 projector;业务 view 只消费一个标准调用块和宿主动作。
     
    +席位填充方还要在每个 root 和 child wrapper 上保留 conversation DOM 契约:`data-chat-anchor-key="call:"`、`data-chat-call-id`,以及 selected call 上的 `data-selected="true"`。`ChatView` 用 anchor key 恢复 prepend/paging 位置;child wrapper 由 Tool owner 独自编排,因此这些属性也由它输出。
    +
     业务插件遵循同一个注册形态:
     
     ```text
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
    index ef2b3e55a0..d78733bb33 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md
    -2026-07-30-web-diff-card.md: c8aed2aa59d82520a2a52523edb9b66d0bb34bf0
    -2026-07-30-web-diff-card.zh.md: c2127844165e5f5c162eb3707fa5c86aa3a536c0
    +2026-07-30-web-diff-card.md: b078da8d0e688d705683599467bd98b5c6a0be48
    +2026-07-30-web-diff-card.zh.md: a9c710df99c1060bbf50b591b2f62120dc7dcbef
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
    index c8aed2aa59..b078da8d0e 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
    @@ -14,7 +14,7 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff`
     
     ## Decision
     
    -`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-tool/src/client/models/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
    +`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-tool/src/client/tool/models/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
     
     The component shares the TUI's single-column framing, line-terminator rule, and distinct-path file count. Line classification differs: Web renders the complete old and new sides, while the TUI derives neutral context and exact changed rows when its bounded comparison completes and labels its whole-side fallback approximate.
     
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
    index c212784416..a9c710df99 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
    @@ -14,7 +14,7 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行
     
     ## Decision
     
    -`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-tool/src/client/models/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
    +`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-tool/src/client/tool/models/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
     
     该组件与 TUI 共用单栏框架、行终止符规则和去重路径计数。两者的行分类不同:Web 渲染完整的变更前后两侧,而 TUI 会在有界比较完成时派生中性上下文和精确变更行,并把整侧回退标记为近似结果。
     
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml
    index 71ea2aabaf..3415ace63b 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
    -2026-07-30-web-read-card-frontend.md: 10ed9d3eaa54c2440cf3fbd00a7e44b76c1acfe9
    -2026-07-30-web-read-card-frontend.zh.md: 931316135470b7e7257f7f9d016b24fe741fdbf5
    +2026-07-30-web-read-card-frontend.md: e659645066b706f35709fead4c11023eb4fe9554
    +2026-07-30-web-read-card-frontend.zh.md: 4fc02e23b45399c01b5c99d388f5ae1726b026c0
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
    index 10ed9d3eaa..e659645066 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
    @@ -10,7 +10,7 @@ The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent car
     
     ## Decision
     
    -`ReadBlock` is a `ui-primitives` component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. `ui-tool/src/client/models/read-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so the two sites cannot disagree.
    +`ReadBlock` is a `ui-primitives` component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. `ui-tool/src/client/tool/models/read-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so the two sites cannot disagree.
     
     **A new `ReadBlock` primitive, not an extension of `CodeBlock`.** `CodeBlock` already does shiki highlighting with a language banner and a copy control, but a read view needs a per-line gutter carrying each line's own file number, which `CodeBlock` renders as a single `
    ` tree with no per-line structure. Extending `CodeBlock` with an optional gutter would push a read-specific concern (windowed line numbers, a "showing N of M" note, a height cap) onto every markdown fence and every `run_code` body that shares that component. Instead `ReadBlock` reuses the part that is genuinely shared: the shiki grammar singleton in `markdown/highlight.ts`. A new `highlightLines(code, lang)` there tokenizes into shiki's own per-line token arrays (`codeToTokens`) rather than the single-`
    ` HTML `highlightToHtml` produces, so the block can place one gutter number per line and still color the content through the same `--shiki-*` custom properties on the same grammar allowlist. The height cap and its head/tail expand arithmetic are copied from `TerminalBlock` (`ceil(max/2)` head plus the remaining tail), so a long read and a long command output collapse at the same place. The copy control writes the window's raw text (the lines joined by newlines), never the gutter numbers or the banner.
     
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
    index 9313161354..4fc02e23b4 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
    @@ -10,7 +10,7 @@ Status: implemented
     
     ## Decision
     
    -`ReadBlock` 是一个 `ui-primitives` 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。`ui-tool/src/client/models/read-card-model.ts` 是把快照的 `resultView` 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。
    +`ReadBlock` 是一个 `ui-primitives` 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。`ui-tool/src/client/tool/models/read-card-model.ts` 是把快照的 `resultView` 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。
     
     **新建一个 `ReadBlock` primitive,而不是扩展 `CodeBlock`。** `CodeBlock` 已经带语言横幅和复制控件做 shiki 高亮,但读取视图需要一个每行带该行自身文件行号的行号栏,而 `CodeBlock` 把内容渲染为单个 `
    ` 树、没有逐行结构。给 `CodeBlock` 加一个可选行号栏会把读取专属的关切(窗口行号、"显示 N / M"提示、高度上限)强加给共享该组件的每个 markdown 代码围栏和每个 `run_code` 程序体。`ReadBlock` 转而复用真正共享的部分:`markdown/highlight.ts` 里的 shiki 语法单例。那里新增的 `highlightLines(code, lang)` 把代码切成 shiki 自己的逐行 token 数组(`codeToTokens`),而不是 `highlightToHtml` 产出的单 `
    ` HTML,于是该 block 能每行放一个行号、同时用同一套 `--shiki-*` 自定义属性、同一份语法白名单给内容上色。高度上限及其头/尾展开算法照抄自 `TerminalBlock`(`ceil(max/2)` 行头部加剩余的尾部),因此长读取和长命令输出在同一处折叠。复制控件写入窗口的原始文本(各行以换行拼接),绝不含行号栏或横幅。
     
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
    index ded72420fc..6f3a5d895b 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
    -2026-07-30-web-result-card-frontend.md: 1d58710dbce3ed1aa9337e1841940195f71db40a
    -2026-07-30-web-result-card-frontend.zh.md: 8705aa05557c1fdf642f498939bc2ceddde91f1f
    +2026-07-30-web-result-card-frontend.md: c7e63220d824cf0bd3ac3536c528707d0db379bc
    +2026-07-30-web-result-card-frontend.zh.md: 4cec11371e8d0848f2f03ded0b093d80cc25a7a5
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
    index 1d58710dbc..c7e63220d8 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
    @@ -10,7 +10,7 @@ The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web
     
     ## Decision
     
    -`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-tool/src/client/models/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
    +`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-tool/src/client/tool/models/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
     
     One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant.
     
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md
    index 8705aa0555..4cec11371e 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md
    @@ -10,7 +10,7 @@ Status: implemented
     
     ## Decision
     
    -`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-tool/src/client/models/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。
    +`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-tool/src/client/tool/models/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。
     
     一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。
     
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml
    index 1578f0c73e..0cfb6b01bd 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml
    @@ -2,5 +2,5 @@
     # side as of the last confirmed-consistent state. Both languages carry equal authority;
     # after editing either side, bring the other along and re-record with:
     #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-search-card.md
    -2026-07-30-web-search-card.md: a350756a22d2ba5da8a0ff5d3a4cb3f257ac566b
    -2026-07-30-web-search-card.zh.md: d6ec2a08eb02a92a50d4742c3d87c22f42c095d4
    +2026-07-30-web-search-card.md: e0be857df69dfd46b6e936c775c6bae1476c26dc
    +2026-07-30-web-search-card.zh.md: 1704b8f04c519511d5afa32ae6683c1e48ce7992
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md
    index a350756a22..e0be857df6 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md
    @@ -12,7 +12,7 @@ This is the follow-up the search render card note names: that PR was the backend
     
     ## Decision
     
    -`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-tool/src/client/models/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation.
    +`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-tool/src/client/tool/models/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation.
     
     The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card.
     
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md
    index d6ec2a08eb..1704b8f04c 100644
    --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md
    @@ -12,7 +12,7 @@ Status: implemented
     
     ## Decision
     
    -`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-tool/src/client/models/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`shape` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `shape` 和分组/扁平内容与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `shape` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(不是 `kind` —— 后端把 `kind` 留给 call view 的选图标签);`SearchBlock` 自身的 prop 仍是 `kind`,由本推导从 `shape` 映射得到。
    +`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-tool/src/client/tool/models/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`shape` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `shape` 和分组/扁平内容与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `shape` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(不是 `kind` —— 后端把 `kind` 留给 call view 的选图标签);`SearchBlock` 自身的 prop 仍是 `kind`,由本推导从 `shape` 映射得到。
     
     与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。
     
    diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
    index 9d0c26506e..bbd4caa2c3 100644
    --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
    +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
    @@ -81,7 +81,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
                 case 'text': return (
                   
                 )
    -            case 'reasoning': return 
    +            case 'reasoning': return 
                 // Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
                 case 'tool-call': return null
                 default: return (
    diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
    index b16d838e77..d487123558 100644
    --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
    +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
    @@ -7,6 +7,7 @@
     import { useState, type ReactNode } from 'react'
     import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
     import { DisclosureRow, IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
    +import a11yCss from './accessibility.module.css'
     import css from './GenericCommandCard.module.css'
     
     type CommandRowState = 'running' | 'ok' | 'error'
    @@ -42,6 +43,7 @@ export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
       const open = expanded && body !== null
       return (
         
    + {state === 'error' && {t('row.failed')}} (null) const summary = running ? latestLine(text) : firstLine(text) @@ -36,6 +39,7 @@ export function ReasoningRow({ text, running }: { text: string; running: boolean return (
    + {running && {t('row.running')}} void } -/** Owner currency of the chat view's whole-Tool rendering seat. */ +/** + * Owner currency of the chat view's whole-Tool rendering seat. The filler + * wraps every rendered root and child with `data-chat-anchor-key="call:"` + * and `data-chat-call-id=""`, plus `data-selected="true"` for the selected + * call. ChatView consumes those anchors to restore prepend/paging position. + */ export interface ToolTreeOwnerProps { /** Root Tool call identity, stable across running → settled. */ callId: CallId diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 0a25e9b198..d2589122e8 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -1269,6 +1269,7 @@ describe('ChatView', () => { const fv = render() expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull() expect(fv.getByText('命令失败')).toBeTruthy() + expect(fv.getByText('失败')).toBeTruthy() // Still executing: running state with the executing copy. const executing = makeHarness({ diff --git a/packages/client/ui-conversation/tests/reasoning-row.spec.tsx b/packages/client/ui-conversation/tests/reasoning-row.spec.tsx index 243b665ce2..62e6ac7848 100644 --- a/packages/client/ui-conversation/tests/reasoning-row.spec.tsx +++ b/packages/client/ui-conversation/tests/reasoning-row.spec.tsx @@ -47,6 +47,7 @@ describe('ReasoningRow', () => { streaming />, ) + expect(view.getByText('运行中')).toBeTruthy() const summary = view.getByText('Newest reasoning tokens') Object.defineProperties(summary, { scrollWidth: { configurable: true, value: 300 }, @@ -76,6 +77,7 @@ describe('ReasoningRow', () => { ) flushAnimationFrames(3) expect(view.getByText('Inspect the session')).toBeTruthy() + expect(view.queryByText('运行中')).toBeNull() expect(summary.scrollLeft).toBe(0) expect(summary.hasAttribute('data-follow-end')).toBe(false) }) diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index eca4d1cb7b..79a4eef640 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md -README.md: 69d6931d33788f2df593919d160c4bfeaef8d15c -README.zh.md: eb49465a743711bd48358bf3953a485f9ef037eb +README.md: bf6213ebfacd8f7963463b2c443524c631c28bcd +README.zh.md: 06a46a525eead005375bcf67794a1ceecde678bc diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 69d6931d33..bf6213ebfa 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -10,6 +10,8 @@ Business UI packages register only their wire Tool names and atomic views. They `ToolCallTree` receives one root `ToolCallBlock`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. Through its standard session slot props it selects the Runtime-projected `codeDispatches[rootCallId]` array, then sends the root and every child through the same atomic dispatch path. The Runtime currently exposes only one Code Dispatch child level, so the renderer preserves that shape instead of inventing recursive data. +Each root and child wrapper preserves the `conversation.chat.tool` call-anchor DOM contract used for paging and selection. + The package also fills `conversation.details.tool` with `ToolDetails`. The row and details renderers share the same pure card models for `terminal`, `read`, `diff`, `search`, and `web` render intents. Unknown intent tags and malformed wire card data fall back to flattened Tool result text. Generic rows classify known Tool names into search, read, shell, write, edit, code, or generic variants. Running, successful, failed, and interrupted lifecycle states come only from the frozen call/result slice. File paths resolve against the session `cwd` only when the user invokes the Host open-file callback; presentation code does not read Session services. @@ -30,6 +32,8 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo This package currently owns the generic fallback and the built-in bash/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`. +Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes. + ## Model Experience None, as this package renders already logged Tool calls and results without altering model requests, Tool execution, or session events. @@ -42,3 +46,4 @@ None. The package is client-only presentation. - The Runtime currently exposes one level of Code Dispatch children. The renderer sends roots and children through the same atomic path, but it does not claim an arbitrary recursive wire topology. - Existing first-party Tool views are initially colocated here and can move to their owning business packages independently through the keyed slot. +- Tool copy temporarily reuses the `ui-conversation` locale namespace. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index eb49465a74..06a46a525e 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -10,6 +10,8 @@ Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交 `ToolCallTree` 接收一个 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,再让 root 与每个 child 经过同一条原子分发路径。Runtime 当前只暴露一层 Code Dispatch child,因此 renderer 保留该形状,不自行发明递归数据。 +每个 root 和 child wrapper 都保留 `conversation.chat.tool` 的 call-anchor DOM 契约,供分页和 selection 使用。 + 本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与详情 renderer 为 `terminal`、`read`、`diff`、`search` 和 `web` render intent 共用同一组纯 card model。本版本不认识的 intent 标签和格式错误的 wire card 数据都会回退为压平的 Tool result 文本。 通用行把已知 Tool 名称归类为 search、read、shell、write、edit、code 或 generic 变体。运行中、成功、失败和中断状态只来自冻结的 call/result slice。只有用户调用 Host 打开文件回调时,文件路径才相对会话 `cwd` 解析;展示代码不读取 Session service。 @@ -30,6 +32,8 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` 本包当前拥有 generic fallback,以及 bash/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包如何拥有 `skill` 注册。 +各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md) 和 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) Note 负责。 + ## 模型体验 无,因为本包只渲染已经记录的 Tool 调用和结果,不改变模型请求、Tool 执行或 Session Event。 @@ -42,3 +46,4 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` - Runtime 当前只暴露一层 Code Dispatch 子调用。renderer 会让 root 和 child 经过同一个原子分发路径,但不宣称 wire 拓扑已经支持任意递归。 - 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。 +- Tool 文案暂时复用 `ui-conversation` locale namespace。 diff --git a/packages/client/ui-tool/tests/ask-question-row.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.spec.tsx index 745bc2ff90..f3ab8fd3fd 100644 --- a/packages/client/ui-tool/tests/ask-question-row.spec.tsx +++ b/packages/client/ui-tool/tests/ask-question-row.spec.tsx @@ -14,7 +14,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' // Export discipline: packages/client/AGENTS.md. import { AskQuestionRow, askQuestionToolview } from '../src/client/tool/toolviews/ask-question-row.tsx' -import { zh } from '../../ui-conversation/src/client/locales.ts' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) diff --git a/packages/client/ui-tool/tests/coverage-tails.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.spec.tsx index 720fa23255..024f97ce76 100644 --- a/packages/client/ui-tool/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.spec.tsx @@ -11,7 +11,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { ToolRow } from '../src/client/tool/components/ToolRow.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' -import { zh } from '../../ui-conversation/src/client/locales.ts' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' type BashRowProps = Parameters[0] diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 0fe95c9564..a0dee040c1 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -16,12 +16,12 @@ import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/cl import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/tool/models/diff-card-model.ts' -import { createChatStore } from '../../ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '../../ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx' import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' -import { zh } from '../../ui-conversation/src/client/locales.ts' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index 8b528355a5..826bd72892 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -19,10 +19,10 @@ import type { import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts' -import { createChatStore } from '../../ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { zh } from '../../ui-conversation/src/client/locales.ts' -import { DetailsPanel } from '../../ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx' import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index 02d3eab476..72e25ac32b 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -18,10 +18,10 @@ import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/cl import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/tool/models/search-card-model.ts' -import { zh } from '../../ui-conversation/src/client/locales.ts' -import { createChatStore } from '../../ui-conversation/src/client/stores.ts' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '../../ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx' import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index 34c5744c01..93a538c5c0 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -16,12 +16,12 @@ import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/cl import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { terminalCardModel, terminalFailed } from '../src/client/tool/models/terminal-card-model.ts' -import { createChatStore } from '../../ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '../../ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' -import { zh } from '../../ui-conversation/src/client/locales.ts' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' type BashRowProps = Parameters[0] diff --git a/packages/client/ui-tool/tests/todo-row.spec.tsx b/packages/client/ui-tool/tests/todo-row.spec.tsx index 326821fc4e..ef3253a947 100644 --- a/packages/client/ui-tool/tests/todo-row.spec.tsx +++ b/packages/client/ui-tool/tests/todo-row.spec.tsx @@ -8,7 +8,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { TodoRow, todoToolview } from '../src/client/tool/toolviews/todo-row.tsx' import { planSummary } from '../src/client/tool/toolviews/plan-summary.ts' import { CONVERSATION_NS as NS } from '../src/client/locale.ts' -import { zh } from '../../ui-conversation/src/client/locales.ts' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' type TodoRowProps = Parameters[0] diff --git a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx index 76b24bc7ac..0720ba5642 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx @@ -7,7 +7,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ToolTreeProps } from '../src/client/contract/slots.ts' import { ToolCallTree } from '../src/client/tool/ToolCallTree.tsx' -import { zh } from '../../ui-conversation/src/client/locales.ts' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) diff --git a/packages/client/ui-tool/tests/tool-details-render.tsx b/packages/client/ui-tool/tests/tool-details-render.tsx index e61cd0aaa7..0aeb3c5321 100644 --- a/packages/client/ui-tool/tests/tool-details-render.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.tsx @@ -1,7 +1,7 @@ /** Test adapter for the production conversation.details.tool registration. */ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import type { DetailsSlotProps, DetailsToolOwnerProps } from '../../ui-conversation/src/client/contract/slots.ts' +import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts' import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' /** Framework session-area seat used by direct DetailsPanel tests. */ diff --git a/packages/client/ui-tool/tests/tool-row.spec.tsx b/packages/client/ui-tool/tests/tool-row.spec.tsx index f53bcb11f7..02189e3634 100644 --- a/packages/client/ui-tool/tests/tool-row.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.spec.tsx @@ -9,7 +9,7 @@ import { resolveWorkspacePath } from '@deepseek-ai/dsh-client-runtime/client' import { classifyTool, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts' import { ToolRow } from '../src/client/tool/components/ToolRow.tsx' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { zh } from '../../ui-conversation/src/client/locales.ts' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(() => { cleanup() diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 743b450ca7..b8a5a4e06c 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -19,14 +19,14 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' import { webCardModel } from '../src/client/tool/models/web-card-model.ts' -import { createChatStore } from '../../ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '../../ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { zh } from '../../ui-conversation/src/client/locales.ts' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) diff --git a/vitest.config.ts b/vitest.config.ts index 597bca5618..6eb1bda354 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -162,6 +162,7 @@ export default defineConfig({ 'packages/client/web-react/src/*', 'packages/client/runtime/src/*', 'packages/client/ui-conversation/src/*', + 'packages/client/ui-primitives/src/DisclosureRow.tsx', 'packages/client/ui-tool/src/*', 'packages/client/ui-slots/src/*', 'packages/client/ui-layout/src/*', From 810bfc4b5e2287349256cc9a35a170b4351a9f35 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:27:09 +0800 Subject: [PATCH 092/100] fix: ci --- .../ui-conversation/src/client/chat/GenericCommandCard.tsx | 2 +- .../client/ui-conversation/src/client/chat/ReasoningRow.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index d487123558..9d34ef3015 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -42,7 +42,7 @@ export function GenericCommandCard({ node, t }: GenericCommandCardProps) { const body = text !== undefined && text.includes('\n') ? text : null const open = expanded && body !== null return ( -
    +
    {state === 'error' && {t('row.failed')}} +
    {running && {t('row.running')}} Date: Sat, 8 Aug 2026 16:43:29 +0800 Subject: [PATCH 093/100] fix(client): keep compact row in conversation package --- .../src/client/chat/CompactionCommandCard.tsx | 14 +------------- .../src/client/chat/GenericCommandCard.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx index 8012834541..c2f0191d85 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx @@ -3,11 +3,9 @@ // generic command card so no-history, cancellation, and failures retain their // complete handler-authored text. -import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' import { CompactionItem } from './CompactionItem.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' -import { ToolRow } from './ToolRow.tsx' interface CompactionCommandCardProps extends CommandRowOwnerProps { t: ChatViewSlotProps['t'] @@ -26,15 +24,5 @@ export function CompactionCommandCard({ node, compaction, t }: CompactionCommand ) } if (node.outcome !== null) return - return ( - } - title="compact" - summary={t('message.compaction.running')} - body={null} - state="running" - /> - ) + return } diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index 9d34ef3015..9137181265 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -25,13 +25,15 @@ function leadingFor(state: CommandRowState): ReactNode { /** Card props: the owner payload plus the render site's locale seat (plain prop). */ export interface GenericCommandCardProps extends CommandRowOwnerProps { t: ChatViewSlotProps['t'] + /** Command-specific running copy; absent uses the generic command label. */ + runningSummary?: string | undefined } -export function GenericCommandCard({ node, t }: GenericCommandCardProps) { +export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) { const [expanded, setExpanded] = useState(false) const text = node.outcome?.text const summary = node.outcome === null - ? t('command.running') + ? runningSummary ?? t('command.running') : text ?? (node.outcome.kind === 'error' ? t('command.failed') : t('command.done')) // Title is the bare command name: the row already reads `name · outcome`, // and the dispatched line's own `/` and arguments only restate what the From 3b31b2eba19c702ecd455ebc22ea8741c03ef2af Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:50:30 +0800 Subject: [PATCH 094/100] fix: ci --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2a396a8404..24e7e70f27 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: c4c7a0363c0a2760cf478744073eab96d837b719 -README.zh.md: a34f99f7e4e50750b2ecf024ed9e898c8de09529 +README.md: 837edaa097d47ebfb72d027616a18fdfeed8a488 +README.zh.md: 419799666dc0689f8fe754d4c9de6d5dcf7fb09c From 871c59c3bcfd0f76e671f9eba4ca1a49451db272 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:12:54 +0800 Subject: [PATCH 095/100] fix(client): address tool tree review --- .../src/client/chat/GenericCommandCard.tsx | 1 + .../ui-conversation/tests/chat-view.spec.tsx | 1 + .../ui-tool/src/client/tool/ToolCallTree.tsx | 31 ++++++++++--------- .../ui-tool/tests/tool-call-tree.spec.tsx | 2 ++ 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index 9137181265..676e433fa5 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -45,6 +45,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa const open = expanded && body !== null return (
    + {state === 'running' && {t('row.running')}} {state === 'error' && {t('row.failed')}} { const xv = render() expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull() expect(xv.getByText('执行中…')).toBeTruthy() + expect(xv.getByText('运行中')).toBeTruthy() // Cross-window soft-fall (run page truncated): generic title, outcome preserved. const orphan = makeHarness({ diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 8091f26dff..3c71e2f4d4 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -1,5 +1,5 @@ /** Root/subcall Tool composition with one keyed atomic dispatch path. */ -import { memo, useMemo } from 'react' +import { memo, useMemo, type ReactNode } from 'react' import type { CodeSubCall, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts' import { GenericToolCard } from './toolviews/GenericToolCard.tsx' @@ -12,12 +12,13 @@ function subCallName(node: CodeSubCall): string { /** One atomic call dispatched through the Tool-owned keyed slot. */ const ToolCall = memo(function ToolCall({ - renderSlot, callId, toolName, block, openFile, selected, cwd, inspectCall, t, + renderSlot, callId, toolName, block, openFile, selected, cwd, inspectCall, t, children, }: Pick & { callId: string toolName: string block: ToolCallBlock selected: boolean + children?: ReactNode }) { const owner: ToolCallOwnerProps = useMemo(() => ({ callId, @@ -38,6 +39,7 @@ const ToolCall = memo(function ToolCall({ entryKey: toolName, fallback: , })} + {children}
    ) }) @@ -53,18 +55,17 @@ export function ToolCallTree({ }: ToolTreeProps) { const subCalls = useSession(snapshot => snapshot.codeDispatches.get(callId)) return ( - <> - + {subCalls !== undefined && subCalls.length > 0 ? (
    {subCalls.map(node => ( @@ -83,6 +84,6 @@ export function ToolCallTree({ ))}
    ) : null} - +
    ) } diff --git a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx index 0720ba5642..c8c31ad365 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx @@ -57,6 +57,8 @@ describe('ToolCallTree', () => { const view = render( , ) + expect(view.container.querySelector('[data-subcalls]')?.parentElement) + .toBe(view.container.querySelector('[data-chat-call-id="parent"]')) expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false) expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.getAttribute('data-selected')).toBe('true') }) From 5934bbc32e34d733a81a57a9daf0e9a9a31caa9a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:17:14 +0800 Subject: [PATCH 096/100] docs(skills): preserve browser GIF evidence chains --- ...08-08-browser-gif-evidence-chain.i18n.yaml | 6 +++ .../2026-08-08-browser-gif-evidence-chain.md | 37 +++++++++++++++++++ ...026-08-08-browser-gif-evidence-chain.zh.md | 37 +++++++++++++++++++ .agents/skills/record-browser-gif/SKILL.md | 32 ++++++++++------ 4 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md create mode 100644 .agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml new file mode 100644 index 0000000000..ea763ef0e8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md +2026-08-08-browser-gif-evidence-chain.md: 9fe24b211e7094d4769af50c8b0ceae5c43fb4be +2026-08-08-browser-gif-evidence-chain.zh.md: a84ea46373d8684389ce0c8c61887906e1fdc025 diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md new file mode 100644 index 0000000000..9fe24b211e --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md @@ -0,0 +1,37 @@ +# Agent Note: Browser GIFs preserve one evidence chain + +Status: implemented + +English | [中文](2026-08-08-browser-gif-evidence-chain.zh.md) + +## Problem + +A browser-demo storyboard can contain individually truthful screenshots without proving one truthful execution. Reusing global application state can admit old settings or sessions, capture automation can accidentally combine frames from separate model runs, and a chat transcript can show a successful fallback without exposing the tool rejection that caused it. Fuzzy accessible-name matching can also accept prompt echoes or descendant text instead of the intended result. + +Headless production recording has two further boundaries. A product default may open a native operating-system surface that automation cannot drive, while replacing that surface with a mock or test hook would change the provenance. After publication, a successful git push does not prove that a private-repository GIF is fetchable or that GitHub recognizes the pull-request Markdown as an image. + +## Decision + +The [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) workflow treats one storyboard as one evidence chain pinned to an exact pull-request head. Each run uses fresh `DSH_HOME`, `DSH_AGENTS_HOME`, workspace, and session state, and every published frame comes from the same server and model-backed scenario run. A failed capture run is discarded and repeated from fresh roots rather than combined with another run. + +Browser automation waits for unique, exact semantic states. When the claim concerns a tool call, rejection, or recovery, the storyboard includes a detail or trajectory frame that identifies the tool, shows its status or stable error code, and shows the downstream result. The final encoded GIF remains the verification subject; when a viewer cannot animate it, representative frames are decoded from that GIF instead of treating source screenshots as equivalent evidence. + +The available browser-control workflow remains preferred. When it is unavailable, the recorder uses the repository-declared Playwright dependency in an isolated headless browser rather than installing another driver or opening the user's browser. A native production surface may be replaced only through normal application configuration with an official browser-operable production backend, and that override is stated in the provenance. Fixtures, mock transports, synthetic events, and test-only hooks do not substantiate a real-production claim. + +Publication verifies the boundary again. The assets branch contains media only, the staged and published bytes match the verified artifact, and a private-repository asset is checked through authenticated API or raw requests for its path, byte size, checksum, response status, and media type. This proves the repository-member review path only; the [documentation-site image decision](2026-08-06-doc-site-carries-its-images.md) owns why a public site cannot depend on a private raw URL. Immediately before the pull-request body changes, the live head must still equal the recorded head; GitHub's Markdown renderer must then produce the expected image without changing that code head. + +## Alternatives considered + +**Allow frames from separate runs when their visible states look equivalent.** Visual similarity does not establish shared state, causal order, or one scenario execution. Re-recording costs another real round but preserves the claim the storyboard makes. + +**Use the chat transcript as sufficient proof of tool recovery.** A final answer proves that the task completed, but it can hide which tool ran, whether the failure was structured, and whether the model recovered from that failure. A trajectory or detail frame carries those facts directly. + +**Replace inaccessible native UI with a fixture or test hook.** That makes automation easier by changing the product path under observation. Selecting an official production backend through normal configuration keeps the exercised implementation real and makes the narrower mode explicit. + +**Trust a successful assets-branch push or an anonymous fetch.** A push proves only that git accepted bytes, while private repositories intentionally reject unauthenticated raw requests. Authenticated byte verification plus GitHub Markdown rendering tests the two publication boundaries that reviewers use. + +## Consequences + +GUI evidence now establishes one causal execution rather than a collage of plausible states, and reviewers can inspect both a structured tool failure and the completed result. Publication detects stale pull-request heads, corrupted or misplaced media, and invalid image Markdown before the body is treated as finished. + +The workflow spends additional scratch state, may repeat a real model round after a capture failure, and usually adds a detail frame plus authenticated publication checks. Headless recordings can use fewer production backends than an interactive desktop, and every such selection remains part of the stated provenance. diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md new file mode 100644 index 0000000000..a84ea46373 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 浏览器 GIF 保留单一证据链 + +Status: implemented + +[English](2026-08-08-browser-gif-evidence-chain.md) | 中文 + +## 问题 + +浏览器演示的分镜可以由每张都真实的截图组成,却无法证明这些截图来自同一次真实执行。复用应用全局状态可能引入旧设置或旧会话;录制自动化可能误将不同模型运行的画面合并;聊天 transcript(文本记录)可能显示降级处理成功,却没有揭示触发降级的工具拒绝。按无障碍名称进行模糊匹配,还可能误把提示词回显或后代文本当成预期结果。 + +无头模式下的生产环境录制还有两道边界。产品默认配置可能打开自动化无法操控的原生操作系统界面,而用 mock 或测试钩子替换该界面会改变证据来源。发布之后,git 推送成功也不能证明私有仓库中的 GIF 可以获取,或 GitHub 能将 PR(Pull Request)的 Markdown 识别为图片。 + +## 决策 + +[`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) 工作流将一套分镜视为一条证据链,并将其固定到精确的 PR head。每次运行都使用全新的 `DSH_HOME`、`DSH_AGENTS_HOME`、工作区和会话状态,所有发布帧均来自同一个服务器及同一次由模型驱动的场景执行。录制失败时,丢弃该次运行并从全新的状态根目录重新执行,不与另一次运行合并。 + +浏览器自动化会等待唯一且精确的语义状态。如果需要证明工具调用、拒绝或恢复,分镜就必须包含详情帧或轨迹帧:标明工具、显示其状态或稳定错误码,并展示后续结果。最终编码出的 GIF 始终是验证对象;如果查看器无法播放动画,应从该 GIF 中解码出代表性帧,而不能将源截图视为等效证据。 + +仍应优先使用已有的浏览器控制工作流。如果该工作流不可用,录制程序应在隔离的无头浏览器中使用仓库已声明的 Playwright 依赖,而不是安装其他驱动或打开用户的浏览器。只有通过正常应用配置选用官方且可由浏览器操作的生产后端,才能替换原生生产界面,并且必须在证据来源说明中注明这一覆盖。fixture(测试前置数据)、mock 传输层、合成事件和测试专用钩子均不能支撑真实生产实现的主张。 + +发布环节会再次验证边界。资产分支只包含媒体文件,暂存和发布的字节必须与已验证产物一致;对于私有仓库中的资产,应通过经身份验证的 API 或原始内容请求,检查其路径、字节大小、校验和、响应状态和媒体类型。这只能证明仓库成员的评审访问路径;[文档站点图片决策](2026-08-06-doc-site-carries-its-images.md)解释了公共站点为何不能依赖私有的原始内容 URL。修改 PR 正文之前,必须再次确认在线 head 仍与录制时的 head 相同;随后还必须确认 GitHub 的 Markdown 渲染器生成了预期图片,且代码 head 没有改变。 + +## 曾考虑的替代方案 + +**只要可见状态看起来等价,就允许使用不同运行的画面。**视觉相似不能证明各画面共享同一状态、具有因果顺序或来自同一次场景执行。重新录制需要再执行一次真实模型场景,但能维持整套分镜所表达的主张。 + +**将聊天 transcript 视为工具恢复的充分证据。**最终答案能证明任务已经完成,却可能隐藏调用了哪个工具、失败是否为结构化失败,以及模型是否从该失败中恢复。轨迹帧或详情帧可以直接承载这些事实。 + +**使用 fixture 或测试钩子替换无法访问的原生 UI。**这种做法通过改变被观察的产品路径来简化自动化。通过正常配置选用官方生产后端,既能保持受测实现真实,也能明确表述所采用的较窄运行模式。 + +**相信资产分支推送成功或匿名请求成功。**推送只能证明 git 接受了相应字节,而私有仓库会有意拒绝未经身份验证的原始内容请求。经身份验证的字节校验与 GitHub Markdown 渲染验证,覆盖了评审者实际使用的两道发布边界。 + +## 后果 + +GUI 证据现在能证明一次具有因果关系的执行,而不是将若干可信状态拼成集合;评审者既可以检查结构化的工具失败,也可以检查最终完成的结果。在 PR 正文被视为完成之前,发布验证可以发现陈旧的 PR head、损坏或位置错误的媒体文件,以及无效的图片 Markdown。 + +该工作流会占用额外的临时状态;录制失败后,可能需要重新执行一次由真实模型驱动的场景;通常还会增加一张详情帧和经身份验证的发布检查。相比交互式桌面,无头录制可使用的生产后端更少;每次选择这类后端时,都必须将其写入证据来源说明。 diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md index 074b8b176e..c34c47d9d7 100644 --- a/.agents/skills/record-browser-gif/SKILL.md +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -7,6 +7,8 @@ description: Record browser or Web UI interaction demos as optimized GIFs using Produce a short, truthful UI demonstration as a local GIF, and — only when the task includes attaching it to a pull request — publish it through the assets-branch workflow at the end of this skill. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size. +The [evidence-chain decision](../../notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md) owns why one storyboard comes from one isolated run and why publication revalidates both the artifact and the demonstrated pull-request head. + ## Every GUI pull request includes a GIF A pull request that changes product-user-visible GUI behavior MUST include a demonstration GIF recorded with this skill and embedded in the pull request body via [the assets-branch workflow](#publish-to-an-assets-branch). @@ -25,22 +27,24 @@ The GIF's provenance is part of the evidence and must be real: a real server boo A GIF for a specific pull request demonstrates that pull request's tree, so stage per pull request: 1. Build the branch tree being demonstrated — here, `pnpm run build && pnpm run build:web` — from the worktree that holds that branch. A GIF recorded against another branch's build misattributes the evidence. -2. Boot one server per port from that tree, giving each recording a fresh scratch workspace directory so leftover sessions cannot appear in frames. Source the root `.env` for the API key through the application's normal path; never echo the key. -3. Start a new session for each recorded scenario so earlier turns do not pollute the frames. +2. Boot one server per port from that tree with fresh scratch `DSH_HOME`, `DSH_AGENTS_HOME`, workspace, and session state so settings or sessions from another run cannot affect the evidence. Source the root `.env` for the API key through the application's normal path; never echo the key. +3. Treat one storyboard as one evidence run: every published frame comes from that server and those state roots, workspace, session, and model-backed scenario run. If capture automation fails, discard its frames and rerun from fresh roots; never splice frames from separate runs. 4. When switching between pull requests, stop the old server by PID or an exact match on its command line. A broad `pkill -f` pattern can match and kill the shell that launched it — including your own. ## Record the flow -1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required. +1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. If it is unavailable, use the repository-declared Playwright dependency in an isolated headless browser; do not install another driver or launch the user's browser. State that fallback in the provenance. 2. Resolve the evidence boundary before recording: identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports. -3. Choose three to six states that tell one story, such as typed, running, settled, and detail. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer. -4. Keep one viewport and crop for every frame, and name frames lexically: `00-initial.png`, `01-typed.png`, and so on. -5. Store frames under the repository's gitignored `.playwright-mcp/` directory — browser-tool screenshots can only be written under the tool's allowed roots, and relative filenames resolve against the repository root. Create the frame subdirectory first (`mkdir -p .playwright-mcp/gif-frames-