Merge remote-tracking branch 'upstream/master' into fix/gui-todo-default-collapsed
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
@@ -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
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/README.md
|
||||
README.md: 3cfbb5154713046846a3bfcb2ccea62c0e4cb6c0
|
||||
README.zh.md: ddecac79519219c4a76cf9ba19edea312eea9d0d
|
||||
README.zh.md: a3369a94c6761e27567b1408d98a81665443f2ef
|
||||
@@ -11,7 +11,7 @@
|
||||
- **生命周期**(顶层文件夹)是 Agent Note 的状态,Agent Note 随状态变化在文件夹之间移动:
|
||||
- **`proposed/`**:实施前评审的提案;尚未构建(或仅部分构建)。
|
||||
- **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后续移动文件、重命名包(package)或更改键名/默认值时,Agent Note 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。
|
||||
- **`rejected/`**:提案经过讨论后被否决。仅当其决策依据仍能避免一种诱人且影响重大的错误时保留;否则删除完整的三个配对文件。
|
||||
- **`rejected/`**:提案经过讨论后被否决。仅当其决策依据仍能避免一种诱人且影响重大的错误时保留;否则删除完整的英文、中文和伴随记录三文件组。
|
||||
- **类别**(嵌套文件夹)是决策的*种类*——见下方[分类](#classification)。
|
||||
|
||||
文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。
|
||||
@@ -28,7 +28,7 @@
|
||||
|---|---|
|
||||
| `feature` | 面向用户或模型的新功能。 |
|
||||
| `bug-fix` | 修正缺陷或弥补事故复盘(postmortem)发现的缺口。 |
|
||||
| `simplification` | 在不增加功能的前提下移除代码、行为或对外表面积。 |
|
||||
| `simplification` | 在不增加功能的前提下移除代码、行为或对外范围。 |
|
||||
| `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇。 |
|
||||
| `process` | 代码**周边**的工具、策略或工作流——门禁、包管理器、vendor 化——不涉及运行时行为。 |
|
||||
| `testing` | 测试基础设施与策略。 |
|
||||
@@ -39,13 +39,13 @@
|
||||
|
||||
当一份 implemented Agent Note 记录的交付决策已经完整落地,且其决策依据不太可能再指导未来工作时,将其归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。
|
||||
|
||||
归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随文件,并修复或删除入站链接。归档时只允许对内容做这些更改。
|
||||
归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。
|
||||
|
||||
封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) 记录了设计依据。
|
||||
|
||||
## 何时需要写一份
|
||||
|
||||
每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
|
||||
每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘存储格式、协议格式(wire format)或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
|
||||
|
||||
更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
|
||||
|
||||
@@ -112,7 +112,7 @@ Status: <status>
|
||||
|
||||
### 曾考虑的替代方案——必需
|
||||
|
||||
每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not <X>?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——正是这些 Agent Note 存在的意义所要防止的。
|
||||
每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not <X>?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——这正是 Agent Note 旨在防止的问题。
|
||||
|
||||
替代方案是记录下来的,不是凭空编造的。日期早于 2026-07-05 且替代方案无法从记录中重建的 Agent Note,在该章节位置放置以下精确注释,门禁仅对格式规范之前的文件接受此注释:
|
||||
|
||||
@@ -122,8 +122,8 @@ Status: <status>
|
||||
|
||||
### 在生命周期之间移动
|
||||
|
||||
将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——即 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写,使之机械化。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。
|
||||
将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——也就是将 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写变成可机械检查的规则。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。
|
||||
|
||||
### 中文对侧文件
|
||||
|
||||
`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。
|
||||
`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文对侧文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。
|
||||
+3
-3
@@ -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-19-gui-layering-and-rpc-protocol.md: 63db4786adcc007d09b7a58824a59f4d1e1e8be1
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: b3037ceb8c172925581d2862ea675e53a7f8c54e
|
||||
# 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: b9718da4725316c64686adef24827e2984d8723d
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: 2add148054e8f97c65600cd719fb4f8e0283f52d
|
||||
@@ -165,7 +165,7 @@ One example row (the table structure is the reading key):
|
||||
|---|---|---|---|
|
||||
| `session.list` | `{ cursor?: string }` (cursor is a reserved seat, unimplemented) | `{ items: SessionSummary[] }` | persisted sessions, updatedAt descending; v1 builds no index |
|
||||
|
||||
The remaining methods (`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`.
|
||||
The remaining methods (`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`.
|
||||
|
||||
### Frames (server→client, named unions)
|
||||
|
||||
@@ -187,7 +187,7 @@ The remaining frame types are not re-copied here; the full unions are `MuxFrame`
|
||||
- **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it).
|
||||
- **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only.
|
||||
- **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears.
|
||||
- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`.
|
||||
- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. (`session.rename` graduated from this list: it appends a user-source `session/title` event.)
|
||||
|
||||
## The client carrier: the AbstractApiClient class family (`fetch/client.ts`)
|
||||
|
||||
|
||||
+2
-2
@@ -163,7 +163,7 @@ export type ResponseValue<K> =
|
||||
|---|---|---|---|
|
||||
| `session.list` | `{ cursor?: string }`(cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 session,updatedAt 倒序;v1 不建索引 |
|
||||
|
||||
其余方法(`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。
|
||||
其余方法(`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。
|
||||
|
||||
### 帧(server→client,具名 union)
|
||||
|
||||
@@ -185,7 +185,7 @@ export type ResponseValue<K> =
|
||||
- **冷 session 隐式 resume**:`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume,并发触发用在途表去重;attach 与否不对客暴露(`running` 已覆盖)。
|
||||
- **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。
|
||||
- **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。
|
||||
- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。
|
||||
- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。(`session.rename` 已从本清单毕业:追加 user 来源的 `session/title` 事件。)
|
||||
|
||||
## 客户端载体:AbstractApiClient 类体系(`fetch/client.ts`)
|
||||
|
||||
|
||||
+2
-2
@@ -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-25-web-input-machine-and-slash-pipeline.md
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: f446f42c9e202afcb404c7a551a4f715228bb8e5
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 8f5e449bb878811b70bc5bc29e4a09bbc1a33bfa
|
||||
+2
-2
@@ -62,8 +62,8 @@ Calls that stay un-evented (registry registration → explicit call → await):
|
||||
|
||||
A trigger/menu/pick pipeline with zero knowledge of "commands":
|
||||
|
||||
- The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique, registration order = group order = polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in registration order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
|
||||
- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
|
||||
- The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
|
||||
- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); a `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
|
||||
- Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core.
|
||||
|
||||
### hub / facade: the resident shell and the strict-session input body
|
||||
|
||||
+2
-2
@@ -62,8 +62,8 @@ occurrence 表与 chip 三投影:
|
||||
|
||||
对"命令"零知识的触发/菜单/pick 管线:
|
||||
|
||||
- service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一、注册序 = 组序 = 轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按注册序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。
|
||||
- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)、pick 编排(outcome → 自派 bail 事件);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
|
||||
- service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。
|
||||
- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)、pick 编排(outcome → 自派 bail 事件);`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
|
||||
- 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。
|
||||
|
||||
### hub / facade:常驻外壳与严格 session 输入体
|
||||
|
||||
+3
-3
@@ -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-26-subprocess-consumer-migration.md: b31f69f1251a7219e168ed7800ba16ff7d6b328c
|
||||
2026-07-26-subprocess-consumer-migration.zh.md: 47e7519a72f5482f255d15d87b483e9295f6cd2c
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md
|
||||
2026-07-26-subprocess-consumer-migration.md: 477dffc2271db08b8986b34645067b247ad7ace7
|
||||
2026-07-26-subprocess-consumer-migration.zh.md: 5e1035872c6e2101e41d2801cccdfb5ef2c088fc
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-26-subprocess-consumer-migration.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, mcp-client and pty-local and the SDK helper each had a third/fourth/fifth copy of the credential scrub — and none of it was swappable or centrally testable.
|
||||
The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, and mcp-client, pty-local, the SDK helper, and the TUI Git probe each carried another credential scrub — and none of it was swappable or centrally testable.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -15,7 +15,7 @@ The seam's vocabulary is now Node-shaped, and every spawner that can ride the se
|
||||
- **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch).
|
||||
- **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome.
|
||||
- **Tree-scoped termination behind one verb**: `terminate()` owns the SIGTERM→grace→SIGKILL escalation (serves the spec's abort signal too, and is a no-op once the tree is gone) — the handle exposes no single-signal `kill(signal?)`, so a consumer cannot skip the grace window; `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. (The stdin-EOF-first dispose ladder initially absorbed from `subagent-subprocess` later moved back out to its one consumer — see the [ladder-ownership Agent Note](2026-07-27-dispose-ladder-to-consumer.md).)
|
||||
- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well.
|
||||
- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam, and credential-shaped names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, case-insensitively. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork), mcp-client (the MCP SDK owns the transport spawn), and the TUI's synchronous Git branch probe — import the function, so environment policy is single-sourced even where process ownership is not. The SDK helper's `scrubEnvironment()` defaults through the function and applies the exported pattern when its caller supplies an explicit environment; the pattern remains public for this production consumer.
|
||||
|
||||
Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is the backend-owned `disposeAcpChild` ladder over the seam's verbs, with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed).
|
||||
|
||||
@@ -23,16 +23,16 @@ Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local`
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and five of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams.
|
||||
**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and six of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams.
|
||||
|
||||
**A single `stdio: 'pipe' | 'inherit' | 'collect'` mode for all three streams at once.** Rejected: real consumers mix modes per stream (lsp: pipe/pipe/collect; acp: pipe/pipe/inherit; bash: data/collect/collect). Per-stream dispositions are exactly Node's shape and avoid a second spawn call for the mixed cases.
|
||||
|
||||
**Migrate pty-local and mcp-client spawns too.** Rejected on ownership grounds, not scope: node-pty's `fork()` allocates the terminal itself, and the MCP SDK's `StdioClientTransport` spawns internally — neither call site is ours to route. They adopt the shared scrub (the part that is policy), and their READMEs say why the spawn stays put.
|
||||
|
||||
**Migrate the test-support launchers (acp-snapshot, loader-smoke) and the SDK package-manager runner.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams, and the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; it shares the scrub instead.
|
||||
**Migrate the test-support launchers (acp-snapshot, loader-smoke), the SDK package-manager runner, and the TUI Git probe.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams; the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; and the TUI probe is synchronous. The production callers share the scrub instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: one implementation of tree signalling, escalation, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired.
|
||||
|
||||
Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor.
|
||||
Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/TUI/test-support spawns remain outside the service by ownership or execution shape, with the scrub as the shared floor and its pattern intentionally exported for explicit-environment consumers.
|
||||
+5
-5
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local 与 SDK helper 则各自持有凭据清除的第三、第四、第五份副本——而这一切既不可替换,也无法集中测试。
|
||||
[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local、SDK helper 与 TUI Git 探测则各自持有另一份凭据清除——而这一切既不可替换,也无法集中测试。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -15,7 +15,7 @@ Status: implemented
|
||||
- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。
|
||||
- **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。
|
||||
- **以进程树为范围的终止,集中在一个动词后面**:`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(也承接 spec 的 abort 信号,进程树消亡后为空操作)——句柄不暴露单信号的 `kill(signal?)`,因此消费方无法跳过宽限窗口;`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界)。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。(最初从 `subagent-subprocess` 吸收的以 stdin EOF 打头的 dispose 阶梯,后来又移回其唯一消费方——见[阶梯归属 Agent Note](2026-07-27-dispose-ladder-to-consumer.md)。)
|
||||
- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。
|
||||
- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上,且凭据形状的名称不区分大小写地包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN`。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)、mcp-client(MCP SDK 拥有传输层的 spawn)与 TUI 的同步 Git 分支探测——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源。SDK helper 的 `scrubEnvironment()` 默认委托给该函数,并在调用方显式传入环境时应用导出的正则;该正则因此生产消费方而保持公开。
|
||||
|
||||
各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 是后端自有的 `disposeAcpChild` 阶梯,经由 seam 的动词运行,携带插件所配置的宽限期)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。
|
||||
|
||||
@@ -23,16 +23,16 @@ Status: implemented
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和五份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。
|
||||
**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和六份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。
|
||||
|
||||
**用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式一次性统辖全部三条流。**否决:真实消费方按流混用模式(lsp:pipe/pipe/collect;acp:pipe/pipe/inherit;bash:data/collect/collect)。按流划分的处置方式恰好就是 Node 的形状,也免去了混用场景的第二个 spawn 调用。
|
||||
|
||||
**把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。
|
||||
|
||||
**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。
|
||||
**迁移 test-support 启动器(acp-snapshot、loader-smoke)、SDK package-manager 运行器与 TUI Git 探测。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;TUI 探测则是同步调用。这些生产调用点改为共享凭据清除。
|
||||
|
||||
## 后果
|
||||
|
||||
换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。
|
||||
|
||||
代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。
|
||||
代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/TUI/test-support 的 spawn 因所有权归属或执行形状留在该服务之外,以凭据清除作为共底线,且为显式环境的生产消费方有意保持该正则导出。
|
||||
+2
-2
@@ -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-28-dsh-native-typescript-source-launch.md
|
||||
2026-07-28-dsh-native-typescript-source-launch.md: 019389f3e5e9229f4359bbd58c95dbb2f14eb24b
|
||||
2026-07-28-dsh-native-typescript-source-launch.zh.md: 2cfff25d228e67ac85a9bc9087fa09ddb64213a0
|
||||
2026-07-28-dsh-native-typescript-source-launch.md: 1ba1dd2663038ad7c49af71f8b428245f7fa3e2b
|
||||
2026-07-28-dsh-native-typescript-source-launch.zh.md: 02f84f34820469ad9e810ae17d79d3fe12b0cd4c
|
||||
+2
@@ -4,6 +4,8 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md)
|
||||
|
||||
> The Node-native launch vector is superseded by [dsh source launch through the tsx ESM hook](2026-07-29-dsh-source-launch-tsx-esm.md): Node 26.0.0 removed `--experimental-transform-types`, and the paths loader described here is deleted. The Cordis-config declaration gate (`verify-cordis-config`), the app-boot fail-loud plugin diagnostic, and the vendored `import type` marks remain current.
|
||||
|
||||
## Problem
|
||||
|
||||
The `dsh` source entry point originally used `tsx` to run `apps/cli/src/bin.ts`, with the same third-party loader implicitly handling both TypeScript transformation and the root tsconfig's `paths` resolution. With Node handling TypeScript natively, it does not apply tsconfig path mappings; resolving through package exports would instead mix potentially stale or nonexistent `lib/` artifacts into the source launch.
|
||||
|
||||
+2
@@ -4,6 +4,8 @@ Status: implemented
|
||||
|
||||
[English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文
|
||||
|
||||
> Node 原生启动向量已被 [dsh 通过 tsx ESM hook 源码启动](2026-07-29-dsh-source-launch-tsx-esm.md) 取代:Node 26.0.0 移除了 `--experimental-transform-types`,本文描述的 paths loader 已删除。Cordis 配置声明门禁(`verify-cordis-config`)、app-boot 的 fail-loud 插件诊断以及 vendor 中的 `import type` 标注仍然有效。
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh` 源码入口原本使用 `tsx` 运行 `apps/cli/src/bin.ts`,TypeScript 转换和根 tsconfig 的 `paths` 解析都由同一个第三方 loader 隐式处理。改由 Node 原生处理 TypeScript 后,Node 不会应用 tsconfig 路径映射;如果改为通过包导出解析,源码启动会混入可能陈旧或不存在的 `lib/` 产物。
|
||||
|
||||
@@ -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-07-29-dsh-source-launch-tsx-esm.md
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: 93fbb248b45efde37d5fbdb1ec4b812ab3332088
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: 48f410bd846e5808cc95180279348a0ac5ba1c95
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: dsh source launch through the tsx ESM hook
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-dsh-source-launch-tsx-esm.zh.md)
|
||||
|
||||
> Supersedes [native TypeScript source launch](2026-07-28-dsh-native-typescript-source-launch.md): Node removed the capability that decision was built on.
|
||||
|
||||
## Problem
|
||||
|
||||
The [native source-launch decision](2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently.
|
||||
|
||||
Startup latency also mattered: the off-thread `module.register()` hooks worker serialized every resolution across threads (~440ms of `makeSyncRequest` wait during TUI boot), and the full tsx default (`--import tsx`) pays ~0.4s in its CJS hook's resolution amplification.
|
||||
|
||||
## Decision
|
||||
|
||||
The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. `bin/dsh`, the root `dsh`/`demo:tui`/`demo:web` scripts, and the Code Mode TUI overlay use the same vector; `bin/dsh` references the hook and tsconfig by absolute checkout paths (bare `tsx/esm` does not resolve from an arbitrary cwd) and pins `TSX_TSCONFIG_PATH` to the root tsconfig. The CJS hook stays off because the CLI source graph is ESM-only; measured TUI time-to-banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain.
|
||||
|
||||
`scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-tasks` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; fixed alongside this change.)
|
||||
|
||||
The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the native chain on Node ≤25 and branch by version.** Rejected: two transformation semantics (amaro versus esbuild) diverge on edge syntax, the launcher grows version probing, and the node-compat matrix must cover both paths — heavy maintenance for an experimental flag that already changed under us. amaro also rejects the `@Inject` decorators `vendor/hmr` uses, so the native path could not boot the shipped default TUI config anyway.
|
||||
|
||||
**Make the source graph erasable-only so Node 26 strip mode accepts it.** Rejected: parameter properties and value namespaces pervade vendored Cordis/cosmokit/loader/schemastery; rewriting them is unbounded churn re-applied on every vendor sync.
|
||||
|
||||
**A repo-owned in-thread loader (`module.registerHooks()` + esbuild or `@swc/core` transform).** Rejected for now: prototypes measured ~0.45s (esbuild path untested end-to-end; SWC breaks on `vendor/hmr`'s decorator + namespace merge in both decorator modes), but it means owning transform correctness and a resolve hook that tsx already provides. Revisit only if the ~0.3s gap becomes a real cost; the profiling evidence lives in the PR discussion.
|
||||
|
||||
**Run built `lib/` for Node 26 and keep native for 24.** Rejected: loses the zero-build development loop on the newest Node line and mixes source and artifact planes.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line.
|
||||
- TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode.
|
||||
- The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures.
|
||||
- Startup improves ~0.4s over the full tsx default (`demo:headless` and ACP keep `--import tsx`; their graphs were not audited for CJS-hook dependence and their launch latency is not on the interactive path).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: dsh 通过 tsx ESM hook 源码启动
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-dsh-source-launch-tsx-esm.md) | 中文
|
||||
|
||||
> 取代[原生 TypeScript 源码启动](2026-07-28-dsh-native-typescript-source-launch.md):Node 移除了该决策所依赖的能力。
|
||||
|
||||
## 问题
|
||||
|
||||
[原生源码启动决策](2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。
|
||||
|
||||
启动延迟同样是问题:off-thread 的 `module.register()` hooks worker 把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整 tsx 默认形态(`--import tsx`)的 CJS hook 解析放大要多付约 0.4s。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only hook 同时负责 TypeScript 转换与 tsconfig `paths` 投影。`bin/dsh`、根目录的 `dsh`/`demo:tui`/`demo:web` 脚本以及 Code Mode TUI overlay 使用同一向量;`bin/dsh` 以 checkout 的绝对路径引用 hook 与 tsconfig(裸的 `tsx/esm` 无法从任意 cwd 解析),并将 `TSX_TSCONFIG_PATH` 固定到根 tsconfig。CJS hook 保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。
|
||||
|
||||
`scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader "仅为已声明运行时依赖映射 workspace import" 的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest 走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;已随本变更修复。)
|
||||
|
||||
node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块 hook 或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**在 Node ≤25 保留原生链并按版本分叉。** 拒绝:两套转换语义(amaro 与 esbuild)在边缘语法上会分歧,启动器要加版本探测,node-compat 矩阵要覆盖两条路径——为一个已经变动过的 experimental flag 付出沉重维护。而且 amaro 也不支持 `vendor/hmr` 使用的 `@Inject` 装饰器,原生路径本来就无法启动随附的默认 TUI 配置。
|
||||
|
||||
**把源码图改成 erasable-only 以适配 Node 26 strip 模式。** 拒绝:参数属性与值 namespace 遍布 vendor 的 Cordis/cosmokit/loader/schemastery;改写是无界 churn,且每次 vendor sync 都要重做。
|
||||
|
||||
**仓库自有的同线程 loader(`module.registerHooks()` + esbuild 或 `@swc/core` 转换)。** 暂拒:原型实测约 0.45s(esbuild 路径未端到端验证;SWC 在 `vendor/hmr` 的装饰器 + namespace 合并上两种装饰器模式都会崩),但意味着自行负责转换正确性和一个 tsx 已经提供的 resolve hook。仅当约 0.3s 的差距成为真实成本时再重启;profiling 证据在 PR 讨论中。
|
||||
|
||||
**Node 26 运行构建产物 `lib/`,24 保留原生。** 拒绝:在最新 Node 版本线上失去零构建开发循环,且混淆源码面与产物面。
|
||||
|
||||
## 结果
|
||||
|
||||
- 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。
|
||||
- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 note "证明 Node 原生转换可用" 的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。
|
||||
- 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。
|
||||
- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 与 ACP 保持 `--import tsx`:其依赖图未就 CJS hook 依赖性做审计,且其启动延迟不在交互路径上)。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
|
||||
2026-07-29-sticky-composer-conversation-scroll.md: 7ceae95dafffdb756ef49bb5612cd4e711eb59ca
|
||||
2026-07-29-sticky-composer-conversation-scroll.zh.md: d925d82f94635b5fe67b0be119c041d003def393
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Fixed header, sticky composer inside the transcript scrollport
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-sticky-composer-conversation-scroll.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The active conversation column split scrolling: the chat (and trajectory) view owned `overflow-y: auto`, while the composer stack sat as a sibling below that scrollport. A wheel gesture over the stats line or input therefore hit a non-scrolling region and did nothing — the transcript only moved when the pointer was over the message list. Long drafts made it worse: the textarea is itself a scrollport, so wheel over the composer could be trapped there. The session header must occupy the top of the column as ordinary chrome (not `position: sticky` inside the scrollport), while the composer must stick to the bottom of the same scrollport as the transcript so wheel over the footer moves the flow.
|
||||
|
||||
## Decision
|
||||
|
||||
While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places a `data-composer-seat` around the whole `'conversation.composer'` chain output (fallback + elected overlay siblings from `overlay: true`). Active CSS sticks that seat with `position: sticky; bottom: 0` so Question/Approval takeovers stay visible when the user is not pinned to the floor; hero CSS centers the fallback stack inside the scroll body. `ConversationSession` keeps a chrome-hidden header + body shell while blank so that tree seat does not change on the first send. The session header remains `flex: none` column chrome above the scrollport when visible. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`.
|
||||
|
||||
Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Sticky header and sticky composer inside one column scrollport.** Rejected for the header: it must occupy the top as fixed layout chrome, not participate in the scrollport's sticky layer.
|
||||
|
||||
**Fixed flex-none composer below the scrollport with wheel forwarding.** Rejected: the product requires the composer to stick inside the transcript scrollport so the footer is part of that scroll hit-testing surface, not a sibling that only forwards deltas.
|
||||
|
||||
**Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; the wrap target is the Session body owned by the resident shell.
|
||||
|
||||
**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
|
||||
|
||||
## Consequences
|
||||
|
||||
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 固定标题栏,sticky 编辑器位于 transcript 滚动容器内
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-sticky-composer-conversation-scroll.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
活跃会话列把滚动拆成两段:聊天(以及 trajectory)视图自有 `overflow-y: auto`,编辑器栈则作为该滚动容器的兄弟节点坐在下方。指针落在统计行或输入区上时,滚轮打在不可滚动区域上因而毫无效果——只有指针在消息列表上时 transcript 才会移动。草稿变长时更糟:textarea 本身也是滚动容器,编辑器上的滚轮可能被截在那里。会话标题栏必须以普通 chrome 占据列顶(不能在滚动容器内 `position: sticky`),而编辑器必须与 transcript 贴在同一滚动容器底部,使页脚上的滚轮能带动内容流动。
|
||||
|
||||
## Decision
|
||||
|
||||
只要存在会话,`ConversationRoot` 就会始终提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并用 `data-composer-seat` 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 Question/Approval 接管仍可见;hero CSS 在滚动主体内居中 fallback 栈。`ConversationSession` 在 blank 时保留隐藏 chrome 的 header + body 壳,使首次发送时树座位不变。可见时会话标题栏仍是滚动容器之上的 `flex: none` 列 chrome。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。
|
||||
|
||||
会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**标题栏与编辑器都在同一列滚动容器内 sticky。** 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。
|
||||
|
||||
**滚动容器下方 flex-none 固定编辑器并转发滚轮。** 否决:产品要求编辑器 sticky 在 transcript 滚动容器内,使页脚成为该滚动命中面的一部分,而不是仅转发增量的兄弟节点。
|
||||
|
||||
**把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;包装目标是常驻壳拥有的 Session 主体。
|
||||
|
||||
**把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。
|
||||
|
||||
## Consequences
|
||||
|
||||
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md
|
||||
2026-07-29-web-details-session-lifecycle.md: d9e0255768f165bed0631b9324e971b57ec7dcae
|
||||
2026-07-29-web-details-session-lifecycle.zh.md: 09452ba80ff240ddca76df239b40ea661566f8e2
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Web details follow the current Session lifecycle
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-web-details-session-lifecycle.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The details entry is Session-scoped, but its preferred grid width is root-scoped. Selecting a different Session replaced the details content without closing that root preference, so the new owner inherited stale viewing geometry. Hero and other unselected states render no Session-scoped details; they need a derived zero track without becoming false owners in the comparison.
|
||||
|
||||
## Decision
|
||||
|
||||
`AppFrame` reads the current Session id and its `blank` summary flag from the authoritative Session projection. It records the last non-blank selected id only when that Session can own details, so hero and other unselected states neither trigger closure nor replace the last Session owner; their rendered details track derives as zero without changing the stored preference. The first Session keeps the default details width; returning to the same Session restores its current width; selecting a different Session closes the root-scoped details preference through the layout store before paint. The per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
The layout store is transient and starts details at its default width. It neither reads nor writes `localStorage`, so reload resets both panel widths and needs no Session-baseline exception. Manual close and reopen inside one unchanged Session retain their existing behavior. The lifecycle effect changes neither the [Workspace-owned New Session flow](../feature/2026-07-25-workspace-ui-product-flow.md), composer drafts, Session navigation, nor concession-chain resizing.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Close details in the New Session click handler.** Rejected because an unselected surface has no Session-scoped details and must not mutate geometry. Closure belongs to the later comparison between two defined Session owners.
|
||||
|
||||
**Persist panel geometry per Session.** Rejected because the product contract needs stale context removed, not a new map of remembered widths. Per-Session geometry would also reopen details when users return, contrary to the chosen close-on-leave behavior.
|
||||
|
||||
**Preserve persisted layout after the Session baseline is ready.** Rejected because it duplicates startup lifecycle in a presentation component solely to validate stale viewing state. Transient defaults make reload deterministic without a readiness flag.
|
||||
|
||||
**Treat every current-projection change as a Session switch.** Rejected because startup materialization, hero, clearing selection, and invalidation are not transitions between two Session owners.
|
||||
|
||||
## Consequences
|
||||
|
||||
Details is open by default, including when the first Session materializes. Switching to a different Session forgets the dragged details width because close writes zero and reopen uses the contract default. Unselected states derive a zero rendered track while leaving the preferred geometry unchanged; returning to the same Session through one of those states restores its width. Reload forgets sidebar and details geometry. The layout behavior test covers initial defaults, first materialization, direct and hero-mediated Session switches, same-Session return, and the absence of layout storage; the keyless browser e2e drives the same owner transitions through the shipped composition while checking the full grid track and browser errors.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Web 详情栏遵循当前会话生命周期
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-web-details-session-lifecycle.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
详情入口由会话作用域拥有,而其首选网格宽度由根作用域拥有。选择不同会话时,系统会替换详情内容,却不会关闭根作用域的该首选宽度,因此新 owner 会继承陈旧的查看几何信息。hero 和其他未选中状态不会渲染会话作用域的详情;其轨道需派生为零宽度,但不能因此在比较中成为伪 owner。
|
||||
|
||||
## 决策
|
||||
|
||||
`AppFrame` 从权威会话投影读取当前会话 id 及其摘要中的 `blank` 标志。它只在该会话能够拥有详情时记录最后一个选中的非 blank 会话 id,因此 hero 和其他未选中状态既不会触发关闭,也不会替换最后一个会话 owner;这些状态下,详情栏轨道的渲染宽度派生为零,但存储的首选宽度不变。首个会话保留详情栏的默认宽度;返回同一会话时恢复其当前宽度;选择不同会话时,系统会先通过布局 store 关闭根作用域存储的详情栏首选宽度,再进行绘制。逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。
|
||||
|
||||
布局 store 是瞬时状态,详情栏以默认宽度启动。它既不读取也不写入 `localStorage`,因此重新加载会重置两个面板的宽度,无需会话基线例外。在同一个未变化的会话内手动关闭和重新打开详情栏,仍保持原有行为。该生命周期 effect 不改变 [Workspace 拥有的 New Session 动线](../feature/2026-07-25-workspace-ui-product-flow.md)、composer 草稿、会话导航或让步链缩放。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在 New Session 点击处理器中关闭详情栏。** 之所以否决:未选中表面没有会话作用域的详情,不得修改几何信息。详情栏是否关闭,应由随后对两个已定义会话 owner 的比较决定。
|
||||
|
||||
**按会话持久化面板几何信息。** 之所以否决:产品契约需要移除陈旧上下文,而不是新增一张保存各宽度的映射。按会话保存几何信息还会在用户返回时重新打开详情栏,与选定的离开即关闭行为相悖。
|
||||
|
||||
**在会话基线就绪后保留持久化布局。** 之所以否决:这会仅为验证陈旧的查看状态,在呈现组件中重复实现启动生命周期。瞬时默认值无需就绪标志即可使重新加载具有确定性。
|
||||
|
||||
**将当前投影的每次变化都视为会话切换。** 之所以否决:启动时的物化、hero、清除选中项和选中状态失效都不是两个会话 owner 之间的过渡。
|
||||
|
||||
## 后果
|
||||
|
||||
详情栏默认打开,首次会话物化时亦然。切换到不同会话会忘记拖动后的详情宽度,因为关闭操作会写入零值,重新打开时则使用契约默认值。未选中状态会将轨道的渲染宽度派生为零,同时保持首选几何信息不变;经由这些状态返回同一会话时,会恢复其宽度。重新加载会忘记侧边栏与详情栏的几何信息。布局行为测试覆盖初始默认值、首次物化、直接及经 hero 中转的会话切换、返回同一会话,以及不存在布局存储的情况;无密钥浏览器 e2e 则通过已交付的组合驱动相同的 owner 过渡,同时检查完整网格轨道和浏览器错误。
|
||||
@@ -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-06-22-acp-subagent-backend.md: a45ce5e34873249969bbe4dabb87a89d10246b3d
|
||||
2026-06-22-acp-subagent-backend.zh.md: 3b6a11efc1ae0427eb5ce3b30029b77b00d6801a
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md
|
||||
2026-06-22-acp-subagent-backend.md: 5f12aa1c08d4f4cfaa35f2f7f4b09ad341c3eae8
|
||||
2026-06-22-acp-subagent-backend.zh.md: 61359246b0c552f6126cd82ae843d489de809a38
|
||||
@@ -34,7 +34,7 @@ ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `ma
|
||||
|
||||
### Security: scrubbed child environment
|
||||
|
||||
The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error.
|
||||
The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|PASSWORD|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`
|
||||
|
||||
### 安全:清洗子进程环境
|
||||
|
||||
子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|SECRET|TOKEN/i`)默认不转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 `config.env` 显式提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。
|
||||
子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|PASSWORD|SECRET|TOKEN/i`)默认不转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 `config.env` 显式提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -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-07-mcp-client-plugin.md: 2a8ad62e3bad1f2ae47100294f5dba25ceb47d12
|
||||
2026-07-07-mcp-client-plugin.zh.md: 9860d5ec805b394eb0c5f59f54349264593a5599
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md
|
||||
2026-07-07-mcp-client-plugin.md: 756a5c4dc9f1152ecb1955d93b8dc47fcf07c661
|
||||
2026-07-07-mcp-client-plugin.zh.md: d2ff04d68033402fbb2718f8624e44d35d860db3
|
||||
@@ -149,7 +149,7 @@ A unified `execute` handler for all tools from one MCP server:
|
||||
|
||||
### Subprocess environment (stdio transport)
|
||||
|
||||
Replicate the `buildChildEnv` + `SENSITIVE_ENV_PATTERN` scrub from `dsh-subagent-acp`: filter ambient env (strip credential-shaped vars matching `/KEY|SECRET|TOKEN/i`), then merge `config.env` on top. Explicit env overrides survive the scrub.
|
||||
Build the child environment from the subprocess seam's shared `scrubbedParentEnv()` base, which removes ambient names matching `/KEY|PASSWORD|SECRET|TOKEN/i` and ambient `DSH_*` names, then merge `config.env` on top. Explicit env overrides survive the scrub.
|
||||
|
||||
### Disconnection / crash
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp
|
||||
|
||||
### 子进程环境(stdio 传输)
|
||||
|
||||
复用 `dsh-subagent-acp` 的 `buildChildEnv` + `SENSITIVE_ENV_PATTERN` 清洗逻辑:过滤环境变量(剥离匹配 `/KEY|SECRET|TOKEN/i` 的凭证形变量),然后将 `config.env` 覆盖合并到顶层。显式配置的 env 不受清洗影响。
|
||||
以子进程服务边界共享的 `scrubbedParentEnv()` 为基础构建子进程环境;该基础环境会移除环境中匹配 `/KEY|PASSWORD|SECRET|TOKEN/i` 的名称以及 `DSH_*` 名称,然后在其上合并 `config.env`。显式配置的 env 覆盖在清洗后仍会保留。
|
||||
|
||||
### 断连 / 崩溃
|
||||
|
||||
|
||||
@@ -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-10-parallel-tool-call-execution.md: c67ae61939a3e7974f9bf729058a57f5576308a1
|
||||
2026-07-10-parallel-tool-call-execution.zh.md: a80317aa951cbf3a9cae0651348c99712a4193d5
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
|
||||
2026-07-10-parallel-tool-call-execution.md: 19f5dc189821433052edfa72613980a2e94e2cae
|
||||
2026-07-10-parallel-tool-call-execution.zh.md: e90e357180ae3684500adf7cba41c5fc1dac5743
|
||||
@@ -10,7 +10,7 @@ An assistant message may contain several sibling `tool-call` blocks. Running the
|
||||
|
||||
Concurrency is a host scheduling concern, not model-facing tool metadata. The loop needs to decide which calls may overlap without hardcoding tool names or exposing scheduler policy in the JSON schema.
|
||||
|
||||
The session log remains authoritative: every started call has an audit event, every started call receives a result, and model history observes results in the original call order regardless of completion order.
|
||||
The session log remains authoritative: every started call has an audit event, ordinary completion and cancellation pair calls with results, and model history observes committed results in the original call order regardless of completion order.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -46,7 +46,7 @@ Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-exe
|
||||
|
||||
Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered.
|
||||
|
||||
An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event.
|
||||
An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls.
|
||||
|
||||
Code Mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler.
|
||||
|
||||
@@ -66,7 +66,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati
|
||||
|
||||
## Verification
|
||||
|
||||
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration.
|
||||
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration.
|
||||
|
||||
Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior.
|
||||
|
||||
@@ -84,6 +84,8 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
|
||||
|
||||
**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy seam.
|
||||
|
||||
**Convert scheduler failures into tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRegistry owns body-invocation state and typed tool outcomes. Internal scheduler failures therefore remain terminal instead of being reclassified as `ABORTED` results.
|
||||
|
||||
**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete.
|
||||
|
||||
**Use fixed-size windows.** Waiting for every call in one window before starting the next leaves capacity idle behind a slow call. The rolling pool preserves the cap without that delay.
|
||||
@@ -101,3 +103,5 @@ Ordered commits may hold a fast result behind a slow earlier sibling. This prese
|
||||
Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step.
|
||||
|
||||
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
|
||||
|
||||
A terminal scheduler failure may leave recorded calls without results before the failed step closes. Waiting for live dispatches preserves quiescence without misreporting those internal failures as tool outcomes.
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
并发属于宿主调度范畴,不是面向模型的工具元数据。循环需要在不硬编码工具名称、不向 JSON Schema 暴露调度策略的前提下,判断哪些调用可以重叠执行。
|
||||
|
||||
会话日志仍是权威记录:每个已启动的调用都有审计事件,都会获得结果;无论完成顺序如何,模型历史都按原始调用顺序观察结果。
|
||||
会话日志仍是权威记录:每个已启动的调用都有审计事件,正常完成和取消都会使调用与结果配对;无论完成顺序如何,模型历史都按原始调用顺序观察已提交的结果。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -46,7 +46,7 @@ Status: implemented
|
||||
|
||||
每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。
|
||||
|
||||
如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。
|
||||
如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。
|
||||
|
||||
Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。
|
||||
|
||||
@@ -66,7 +66,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c
|
||||
|
||||
## 验证
|
||||
|
||||
单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文,以及中止排空。第一方测试固定每项并行声明。
|
||||
单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定每项并行声明。
|
||||
|
||||
快照覆盖固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 覆盖固定其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。
|
||||
|
||||
@@ -84,6 +84,8 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c
|
||||
|
||||
**公开分阶段方法或调度 waterfall。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在出现另一个消费方之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略 seam 保留了插入点。
|
||||
|
||||
**将调度器故障转换为工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRegistry 负责工具主体调用状态和类型化工具结果。因此,内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。
|
||||
|
||||
**在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。
|
||||
|
||||
**使用固定大小的窗口。** 如果在启动下一个窗口前等待当前窗口的每个调用,一个缓慢调用就会使容量闲置。滚动池在保持上限的同时避免了这项延迟。
|
||||
@@ -101,3 +103,5 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c
|
||||
并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。
|
||||
|
||||
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
|
||||
|
||||
终态调度器故障可能会在故障步骤关闭前留下已记录但没有结果的调用。等待仍在运行的派发可确保完全停稳,而不会将这些内部故障误报为工具结果。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
|
||||
2026-07-16-persistent-pty-sessions.md: 691ccd4341837d63bb48d27c2a3fb007657fe7ba
|
||||
2026-07-16-persistent-pty-sessions.zh.md: 86d57f79ee74429542730ebcad60bc9f3c9f15ca
|
||||
2026-07-16-persistent-pty-sessions.md: d7d06dc8517780a37889e4f94dd0b99475dc5432
|
||||
2026-07-16-persistent-pty-sessions.zh.md: 7783fa4b4f3056ab3b3088a4e9618f45d030ccbf
|
||||
@@ -40,7 +40,7 @@ Agent-scope disposal closes registrations first, then awaits quiescent teardown
|
||||
|
||||
A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-pty-local` therefore applies two protections before spawning:
|
||||
|
||||
- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them.
|
||||
- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*PASSWORD*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them.
|
||||
- It requires `ctx.sandbox` and the shared `ctx.sandboxPolicy`. At spawn, the backend resolves the owner's effective session mode over the deployment default and wraps the shell argv once; that mode and workspace root remain the process boundary for the PTY lifetime. A write that would change the effective `sandbox/mode` is rejected before commit while the owner has any open PTY or unpublished spawn, with an instruction to wait for creation to settle and close those sessions first; same-effective-mode writes remain valid. The pending reservation spans backend setup through publication, so there is no race in which a wider terminal appears after a downgrade. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass.
|
||||
|
||||
Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary.
|
||||
|
||||
@@ -40,7 +40,7 @@ agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出
|
||||
|
||||
注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护:
|
||||
|
||||
- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。
|
||||
- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*PASSWORD*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。
|
||||
- 它要求 `ctx.sandbox` 和共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode,并只包装一次 shell argv;该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。只要 owner 有任何已打开的 PTY 或尚未发布的 spawn,任何会改变生效 `sandbox/mode` 的写入都会在提交前被拒绝,并提示先等待创建操作结算,再关闭这些会话;不会改变生效模式的写入仍然有效。这项进行中的预留从后端 setup 持续到发布完成,因此不存在降级后又出现权限更宽的终端这一竞态。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。
|
||||
|
||||
沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。
|
||||
|
||||
@@ -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-21-log-backed-session-titles.md
|
||||
2026-07-21-log-backed-session-titles.md: 1bd58e35ec625fb0b04c0c119ce425ff30a64881
|
||||
2026-07-21-log-backed-session-titles.zh.md: 37ec95efbca334f71d19d2bc3e18c22d50d9b5fb
|
||||
2026-07-21-log-backed-session-titles.md: 81ac687c6f55dd0ca1eaeb9d84c811edcfe17b5c
|
||||
2026-07-21-log-backed-session-titles.zh.md: b0c7e9d76a1b9365fa16dcb223b390b5aec3e174
|
||||
@@ -36,9 +36,13 @@ Model providers require explicit word, CJK-character, input-byte, output-token,
|
||||
|
||||
Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before log acceptance.
|
||||
|
||||
### Explicit rename
|
||||
|
||||
`rename(session, title)` accepts a user title synchronously: it normalizes the text under the accepted-title byte limit, supersedes in-flight automatic work, and appends a `session/title` event with the third source kind, `user`. A user-sourced latest title pins the session: `onUserMessage` schedules no automatic revision while it stands, under either cadence. An explicit `refresh()` remains the deliberate unpin — it appends a provider or fallback event over the pinned one whenever a replacement title is derivable (an underivable fallback, e.g. under a tiny byte cap, leaves the pin standing). The Web host exposes this as the `session.rename` unary method (resuming cold sessions first) and returns the normalized title plus its event seq so the client settles its `title` projection cell before the push frame arrives.
|
||||
|
||||
### Forks and consumers
|
||||
|
||||
A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages.
|
||||
A fork inherits seed title events unchanged, like the rest of its source log — a pinned (user-sourced) title stays pinned in the child until an explicit refresh. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages.
|
||||
|
||||
`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `<session title> — <configured product title>` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `<session title> — <existing HTML title>` only for a selected titled session and otherwise preserves the product title. Consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold, so a later between-turn title record cannot replace the preceding message-triggered outcome.
|
||||
|
||||
@@ -59,4 +63,4 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th
|
||||
- A fallback appears immediately. Each fresh Web session adds one first-message auxiliary call; other compositions choose whether better titles justify model cost and whether later prompts should retitle a session.
|
||||
- Auxiliary request records and late accepted titles consume event seqs without consuming turn numbers, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change.
|
||||
- One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider.
|
||||
- Manual rename, deletion, generated-versus-user precedence, search, and list indexing remain outside the capability.
|
||||
- Deletion (unpinning without an explicit refresh), search, and list indexing remain outside the capability.
|
||||
@@ -36,9 +36,13 @@ Status: implemented
|
||||
|
||||
自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq;服务会在日志接受前对其进行规范化并施加字节限制。
|
||||
|
||||
### 显式重命名
|
||||
|
||||
`rename(session, title)` 同步接受用户标题:按已接受标题的字节上限规范化文本、取代在途自动工作,并追加一条第三种来源 `user` 的 `session/title` 事件。最新标题来源为 user 即钉住该会话:只要它还在,`onUserMessage` 在任一节奏下都不再安排自动修订。显式 `refresh()` 仍是有意的解钉手段——只要能推导出替代标题,它就在被钉住的标题之上追加提供方或回退事件(推导不出回退标题时,例如字节上限过小,钉住状态保持不变)。Web host 将其暴露为 `session.rename` unary 方法(冷会话先恢复),并返回规范化后的标题及其事件 seq,使 client 在推送帧到达前就结算自己的 `title` 投影格。
|
||||
|
||||
### Fork 与消费方
|
||||
|
||||
与源日志的其他部分相同,fork 会原样继承作为种子的标题事件。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。
|
||||
与源日志的其他部分相同,fork 会原样继承作为种子的标题事件——被钉住(user 来源)的标题在子会话中保持钉住,直到显式 refresh。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。
|
||||
|
||||
`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 `<session title> — <configured product title>`。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 `<session title> — <existing HTML title>`,否则保留产品标题。报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的轮次间标题记录无法取代此前由消息触发的结果。
|
||||
|
||||
@@ -59,4 +63,4 @@ Status: implemented
|
||||
- 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。
|
||||
- 辅助请求记录和延迟接受的标题会占用事件 seq,但不会占用轮次编号,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。
|
||||
- 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。
|
||||
- 手动重命名、删除、生成标题与用户标题的优先级、搜索和列表索引不在此功能范围内。
|
||||
- 删除(不经显式 refresh 的解钉)、搜索和列表索引不在此功能范围内。
|
||||
@@ -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-22-docked-web-goal-bar.md
|
||||
2026-07-22-docked-web-goal-bar.md: 52a7d223ce3522c5ba977b1126dcd63bd2f6366f
|
||||
2026-07-22-docked-web-goal-bar.zh.md: e4842a03ccb8a29b35c7af0c03c51b1324b6ab36
|
||||
2026-07-22-docked-web-goal-bar.md: 110aea299a260896b0098f10b337734e0c0aebcf
|
||||
2026-07-22-docked-web-goal-bar.zh.md: cc0a5eda6815e6c02e97fd02197659764d4f2d69
|
||||
@@ -12,9 +12,9 @@ The web UI had no goal surface at all: the goal stack shipped with model tools,
|
||||
|
||||
`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a new props-driven, self-contained component; `ConversationRoot` mounts it immediately before the composer `InputBar`. The strip's CSS mirrors the composer's horizontal geometry (32px side padding, 776px centered cap) plus the mock's 12px inset, and a -10px bottom margin eats InputBar's 8px top padding and tucks its square bottom edge 2px under the composer card's top edge. All strip states share one fixed 38px height so switching between them never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome.
|
||||
|
||||
Visibility drives the label and actions: active shows "Ongoing Goal" with edit/clear; paused shows "Paused Goal" and adds a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it.
|
||||
Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it.
|
||||
|
||||
`GoalBarActions` lives in the contract layer (`contract/slots.ts`, next to the `ConversationInjected.goalActions` slot it feeds) and carries exactly the rendered verbs: `onEdit`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref.
|
||||
`GoalBarActions` lives in ui-goal's slot contract (`packages/client/ui-goal/src/client/slots.ts`) and carries exactly the rendered verbs: `onEdit`/`onPause`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref.
|
||||
|
||||
The runtime session gains the goal surface the strip (and future UI) needs: `fetchGoal` populates the snapshot on open, and a live `context/message` carrying `goal/change` meta triggers a coalesced refetch — concurrent triggers share the in-flight `goal.get`, while a trigger received during that read schedules one coalesced trailing read so independently ordered notifications and GET responses cannot leave stale state. Window replays never refetch, and matching the meta kind (rather than a goal key) also catches clear tombstones written by other clients. The six mutation verbs fold transport failures into `{ ok: false }` results like every sibling session method, and a get result older than a mutation response that landed mid-flight is dropped.
|
||||
|
||||
@@ -22,18 +22,18 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions.
|
||||
`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip cannot dock into the composer card.
|
||||
- **Render a "Loading goal…" placeholder for `undefined`** — rejected: the strip would flash and collapse on every session open, chrome noise for a sub-second state.
|
||||
- **Include an inline create affordance when no goal is set** — rejected after implementation review: goal creation lives on the `/goal` command, matching the pattern where the model creates goals on request; the bar is a status indicator, not a creation surface.
|
||||
- **Carry the full verb set (`onPause`/`onComplete`) in `GoalBarActions`** — rejected as speculative generality: no consumer calls them, so the interface carries only the rendered verbs.
|
||||
- **Carry the full verb set (`onComplete` included) in `GoalBarActions`** — rejected as speculative generality: the interface carries only the rendered verbs (`onPause` joined it when the active strip gained its pause action).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Goal presence in the web UI is a composer-docked strip: sparkle, phase label, truncated objective, and edit/clear (plus resume when paused) — the browser client's first goal surface.
|
||||
- Goal presence in the web UI is a composer-docked strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface.
|
||||
- The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads).
|
||||
- Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; pause/complete remain available to other surfaces (`/goal`, model tools).
|
||||
- Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools).
|
||||
- `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job.
|
||||
@@ -12,9 +12,9 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T
|
||||
|
||||
`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个新的、由 props 驱动的自包含组件;`ConversationRoot` 将它挂载在输入框 `InputBar` 紧上方。横条的 CSS 对齐输入框的水平几何(两侧 32px 内边距、776px 居中上限),再加上设计稿的 12px 内缩,并用 -10px 的下外边距吃掉 InputBar 的 8px 上内边距,使它方形的底边收进输入框卡片顶边之下 2px。横条的所有状态共享固定的 38px 高度,状态切换不会引起尺寸变化。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。
|
||||
|
||||
可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供编辑/清除;paused 状态显示 "Paused Goal",并增加一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。
|
||||
可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供暂停/编辑/清除;paused 状态显示 "Paused Goal",把暂停换成一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。
|
||||
|
||||
`GoalBarActions` 位于 contract 层(`contract/slots.ts`,紧挨它所喂给的 `ConversationInjected.goalActions` 槽位),只携带实际渲染的动词:`onEdit`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref,因此 UI 不传 ref。
|
||||
`GoalBarActions` 位于 ui-goal 的槽位契约(`packages/client/ui-goal/src/client/slots.ts`),只携带实际渲染的动词:`onEdit`/`onPause`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref,因此 UI 不传 ref。
|
||||
|
||||
运行时会话获得了横条(以及未来 UI)所需的目标表面:`fetchGoal` 在打开时填充快照;携带 `goal/change` 元数据的 live `context/message` 触发合并重新拉取——并发触发器共享正在执行的 `goal.get`,读取期间收到的触发器会安排一次合并后的尾随读取,避免彼此独立排序的通知和 GET 响应留下陈旧状态。窗口重放绝不触发重新拉取,且匹配元数据 kind(而不是 goal 键)还能捕获其他客户端写入的清除墓碑。六个变更动词与所有同类会话方法一样,把传输层失败折叠为 `{ ok: false }` 结果;比在拉取途中落地的变更响应更旧的 get 结果会被丢弃。
|
||||
|
||||
@@ -22,18 +22,18 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。
|
||||
`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;active 横条触发暂停;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;放在头部的横条无法停靠进输入框卡片。
|
||||
- **为 `undefined` 渲染 "Loading goal…" 占位**:不予采纳,每次打开会话横条都会闪现再坍缩,对一个不到一秒的状态来说只是界面噪音。
|
||||
- **未设置目标时在横条内提供内联创建入口**:实现评审后不予采纳,创建目标的职责在 `/goal` 命令上,与模型按请求创建目标的模式一致;横条是状态指示器,不是创建入口。
|
||||
- **在 `GoalBarActions` 中携带完整动词集合(`onPause`/`onComplete`)**:作为投机性泛化不予采纳,没有消费方调用它们,接口只携带实际渲染的动词。
|
||||
- **在 `GoalBarActions` 中携带完整动词集合(含 `onComplete`)**:作为投机性泛化不予采纳,接口只携带实际渲染的动词(active 横条获得暂停操作后,`onPause` 随之加入)。
|
||||
|
||||
## 后果
|
||||
|
||||
- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及编辑/清除(暂停时另有恢复)——这是浏览器客户端的第一个目标界面。
|
||||
- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。
|
||||
- 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。
|
||||
- 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;暂停/完成对其他界面(`/goal`、模型工具)照常可用。
|
||||
- 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;完成对其他界面(`/goal`、模型工具)照常可用。
|
||||
- `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md
|
||||
2026-07-27-tmux-location-context.md: bac5861f7f55c259de04d153115f164d90c415ad
|
||||
2026-07-27-tmux-location-context.zh.md: 03cd722381c45604f7aae8f3d0a9f9fbb8b12bb5
|
||||
@@ -0,0 +1,61 @@
|
||||
# Agent Note: tmux-location context
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-tmux-location-context.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed.
|
||||
|
||||
tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and `tmux display-message -t "$TMUX_PANE" -p '<format>'` prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy.
|
||||
|
||||
**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design.
|
||||
|
||||
**Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider.
|
||||
|
||||
**Detect a real pane by tty, not by `$TMUX_PANE` alone.** `$TMUX_PANE` is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries `$TMUX`/`$TMUX_PANE` from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with `ps -o tty= -p <pid>` (the agent's own pid, passed in-process) and compares it to the pane's `#{pane_tty}`; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking `$TMUX` instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators.
|
||||
|
||||
**Own location and layout only.** The queried fields are session name, window index/name, pane index/id, window/pane active flags, and `window_layout`. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (`capture-pane`), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output.
|
||||
|
||||
**Inject only on change, with optional interval floor.** When due, the plugin calls `agent.inject()` for one `user/message` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional `refreshIntervalMs` (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one.
|
||||
|
||||
### Text
|
||||
|
||||
```text
|
||||
tmux location (turn <turn>):
|
||||
session <session>, window <index> "<name>", pane <index> <pane-id>
|
||||
window active=<0|1>, pane active=<0|1>, layout <window-layout>
|
||||
```
|
||||
|
||||
The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position.
|
||||
|
||||
### Durability and request reconstruction
|
||||
|
||||
Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback.
|
||||
|
||||
The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests.
|
||||
|
||||
## Consequences
|
||||
|
||||
An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one `tmux display-message` subprocess (through the sandboxed bash seam) on the first step of each turn that is due. The optional interval floor is checked before the query and so suppresses both; an unchanged location is detected only by comparing the returned state, so it suppresses the injection while still paying for the query.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal, and a contained executor rejection from either `resolve()` or `run()` that warns instead of failing the turn); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Push from a tmux hook / background watcher** — rejected: requires installing hooks in the user's tmux and a background process with teardown, to gain mid-step freshness that per-turn context does not need.
|
||||
- **Run every step** — rejected: location is stable within a turn; re-querying adds token cost without new information. Gating on `step === 1` yields per-turn readings.
|
||||
- **Raw `child_process`** — rejected: bypasses the sandbox/policy seam and hand-rolls subprocess code the `ctx.bash` executor already owns.
|
||||
- **Include pane/window pixel sizes** — rejected: sizes churn on every resize and add noise; the layout tree already conveys structure.
|
||||
- **Scrape sibling panes with `capture-pane`** — rejected: large, noisy, and privacy-sensitive; out of scope for "own location".
|
||||
- **Dynamic system-prompt section** — rejected: replacing a value erases the earlier readings behind prior reasoning and is not reconstructable; one durable attributed message records each location where it became visible.
|
||||
- **Trust `$TMUX_PANE` (or `$TMUX`) presence** — rejected: both are inherited by terminals launched from a tmux shell (VS Code integrated terminal), so a non-pane process injects a stale location. The pane `#{pane_tty}` vs. this process's controlling tty is the definitive check.
|
||||
- **Denylist known terminal emulators (e.g. `TERM_PROGRAM=vscode`)** — rejected: a partial, ever-growing list that still misses other launchers; the tty match is exact and launcher-agnostic.
|
||||
- **A runtime invariant validating each reading's turn, position, and format** — shipped initially, then removed: it re-derived the producer's own scheduling from the log and asserted a regex over text the same package had just rendered, so it restated `apply()` rather than checking an independent relation. Every failure it could report required an edit to this package, which its pipeline tests already catch. Reintroduce a companion check only for a relation the plugin does not itself compute — for example if readings gain cross-turn ordering or enclosure obligations that another package can violate.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Agent Note:tmux 位置上下文
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-tmux-location-context.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
运行在 tmux 内的 agent 无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让"下方的 pane""这个 window"之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。
|
||||
|
||||
tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所在 pane,`tmux display-message -t "$TMUX_PANE" -p '<format>'` 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。
|
||||
|
||||
**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。
|
||||
|
||||
**通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。
|
||||
|
||||
**以 tty 判定真实 pane,而非仅凭 `$TMUX_PANE`。** `$TMUX_PANE` 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 `$TMUX`/`$TMUX_PANE`,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 `ps -o tty= -p <pid>`(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 `#{pane_tty}` 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作"不在 tmux 中"。改为检查 `$TMUX` 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。
|
||||
|
||||
**仅自身位置与布局。** 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 `window_layout`。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(`capture-pane`),使读数保持小巧,并避免抓取无关、可能敏感的输出。
|
||||
|
||||
**仅在变化时注入,并可选间隔下限。** 需要时,插件调用 `agent.inject()` 注入一条来源为 `{ kind: 'plugin', plugin: 'tmux-context' }` 的 `user/message`。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩与进程恢复存续,无需进程内缓存。可选的 `refreshIntervalMs`(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。
|
||||
|
||||
### 文本
|
||||
|
||||
```text
|
||||
tmux location (turn <turn>):
|
||||
session <session>, window <index> "<name>", pane <index> <pane-id>
|
||||
window active=<0|1>, pane active=<0|1>, layout <window-layout>
|
||||
```
|
||||
|
||||
轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。
|
||||
|
||||
### 持久性与请求重建
|
||||
|
||||
每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。
|
||||
|
||||
发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。
|
||||
|
||||
## 后果
|
||||
|
||||
启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 `tmux display-message` 子进程(经沙箱化的 bash seam)。可选的间隔下限在查询之前检查,因此同时抑制查询与注入;而位置未变化只能通过比较查询返回的状态得知,因此它只抑制注入,查询开销仍会付出。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消,以及 `resolve()` 或 `run()` 抛出的执行器拒绝被兜住并记录警告而非使该轮失败);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **由 tmux hook / 后台监视器推送**——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。
|
||||
- **每个 step 都运行**——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 `step === 1` 为门槛得到按轮次读数。
|
||||
- **裸 `child_process`**——否决:绕过沙箱/策略 seam,并手写 `ctx.bash` 执行器已拥有的子进程代码。
|
||||
- **包含 pane/window 像素尺寸**——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。
|
||||
- **用 `capture-pane` 抓取相邻 pane**——否决:庞大、嘈杂且涉及隐私;超出"自身位置"范围。
|
||||
- **动态系统提示区块**——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。
|
||||
- **信任 `$TMUX_PANE`(或 `$TMUX`)存在即可**——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 `#{pane_tty}` 与本进程控制终端的比对才是决定性检查。
|
||||
- **对已知终端模拟器设黑名单(如 `TERM_PROGRAM=vscode`)**——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。
|
||||
- **用运行时 invariant 校验每条读数的轮次、位置与格式**——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 `apply()`,而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的管线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
|
||||
2026-07-27-trajectory-inspection-ledger.md: 8c2a7c42b7898776de5d42459b09c0fb1737ec0b
|
||||
2026-07-27-trajectory-inspection-ledger.zh.md: 6c5733046dc2cfd3fd2bd005f4cf5c2d2bd110af
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Trajectory inspection ledger
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-trajectory-inspection-ledger.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Trajectory has to make prose, machine payloads, token usage, timing, and nested tool activity readable in the same viewport. The earlier stacked Turn and Step cards preserved hierarchy but spent too much vertical space on repeated chrome, while a completely flat table would erase the causal structure that makes a trajectory useful. Role colors also risked borrowing success and warning semantics, which made visual decoration indistinguishable from runtime state.
|
||||
|
||||
## Decision
|
||||
|
||||
**Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.**
|
||||
|
||||
- The ledger keeps session events in sequence within rewind-delimited branches. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests.
|
||||
- Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector.
|
||||
- Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack.
|
||||
- Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus.
|
||||
- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory subscribes to that source, exhausts its paging only while mounted, and lazily derives its event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer.
|
||||
- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Complete history makes global Request numbering and cumulative usage session-wide rather than tail-window-relative.
|
||||
- Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas.
|
||||
- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data.
|
||||
- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
|
||||
- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
|
||||
- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Copy Vite DevTools fonts, colors, glass surfaces, or component shapes.** Rejected: those choices express a different product identity. The implementation only adopts the transferable method: neutral structure, semantic accents, machine-data typography, dense scanning, and shadows reserved for floating layers.
|
||||
|
||||
**Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower.
|
||||
|
||||
**Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows.
|
||||
|
||||
**Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state.
|
||||
|
||||
**Keep timing in a separate Waterfall tab.** Rejected: the placeholder summarized node counts rather than record timing and forced users to switch away from the rows they wanted to focus. A full-domain Overview keeps timing and filtered records in one visual context.
|
||||
|
||||
**Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces.
|
||||
|
||||
## Consequences
|
||||
|
||||
Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin projection, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview, and inspector through the real client composition.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note:轨迹检查记录表
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-trajectory-inspection-ledger.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
轨迹视图需要在同一视口内清晰呈现正文、机器载荷、token 用量、计时数据和嵌套工具活动。此前堆叠式的轮次与步骤卡片虽然保留了层级,却在重复界面框架上耗费了太多垂直空间;完全扁平化的表格又会抹去因果结构,而这种结构正是轨迹视图的价值所在。角色配色还可能借用成功与警告语义,使视觉装饰与运行时状态无法区分。
|
||||
|
||||
## 决策
|
||||
|
||||
**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。**
|
||||
|
||||
- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。
|
||||
- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。
|
||||
- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。
|
||||
- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。
|
||||
- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 订阅该数据源,仅在挂载期间补齐全部历史,并按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。
|
||||
- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。完整历史使全局请求编号和累计用量以整个会话为范围,而不是相对于末尾窗口。
|
||||
- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。
|
||||
- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。
|
||||
- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。
|
||||
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
|
||||
- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**照搬 Vite DevTools 的字体、颜色、玻璃表面或组件形状。** 不予采纳:这些选择表达的是另一种产品身份。实现仅吸收可迁移的方法,即中性结构、语义强调色、机器数据排版、紧凑扫读,以及只为浮层保留阴影。
|
||||
|
||||
**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。
|
||||
|
||||
**不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构。
|
||||
|
||||
**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。
|
||||
|
||||
**将计时保留在独立的 waterfall 标签页中。** 不予采纳:占位实现汇总的是节点数而非记录计时,并迫使用户离开想要聚焦的记录。保留完整时间范围的 Overview 区域让计时和筛选后的记录处于同一视觉上下文中。
|
||||
|
||||
**修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。
|
||||
|
||||
## 后果
|
||||
|
||||
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间与耗时数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定投影、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 区域与检查器。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
|
||||
2026-07-28-web-terminal-card.md: 14896b1d88e5cfd2e4c58830c7a1bca1e54ed823
|
||||
2026-07-28-web-terminal-card.zh.md: 16c9004f8f80b720b25b76ba5c04f308b0fccbaf
|
||||
@@ -0,0 +1,70 @@
|
||||
# Agent Note: Web terminal card — the bash render intent reaches the browser
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-web-terminal-card.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
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 TUI already renders 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 `<pre>` 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-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.
|
||||
|
||||
The component's contract:
|
||||
|
||||
- **Prompt lines, one per command line.** Each line of the command gets its own row: label, then that line verbatim. A `command` carrying two shell commands on two lines therefore reads as the two commands it is, instead of collapsing into one ellipsized row. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. A trailing newline is a terminator, not an empty final command. Only the FIRST row carries the label: the view knows one working directory — where the call started — and a later line may run somewhere else entirely, since a `cd` in the command is enough to move it. Repeating the label down the rows would state a directory per line that nothing here knows, which is the same reason the run-state dot appears once. Later rows keep a bare `$` so they still read as prompts.
|
||||
- **One run-state dot for the call, on the first row.** `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter the card reserves as its OWN left padding, so it neither indents its command nor depends on the command's text metrics to line up. The reservation is padding rather than margin because every render site rewrites `margin` wholesale to set its own indent, which silently cancelled a margin-based gutter and let a container clip the dot. Exactly one dot, whatever the line count: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per line would assert of a line that succeeded inside a failing call that the line itself failed. The single visually hidden text label carries the same scope, since `StateDot` is `aria-hidden` and one label per row would read to assistive technology as several distinct outcomes.
|
||||
- **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding.
|
||||
- **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends.
|
||||
- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Cursor movements resolve before that strip, into a per-line column buffer rather than by string surgery, because carriage return and backspace only MOVE the cursor — neither erases anything, so what a reader sees is whatever each column last had written to it. `100%` then a carriage return and `OK` shows `OK0%`, since the redraw is shorter than the frame beneath it; a trailing `abc` plus a backspace still shows `abc`, since nothing overwrote the `c`; `abc` plus two backspaces and `XY` shows `aXY`. Each of these was checked against a real terminal, because the earlier truncate-and-delete approximations looked right and were not. SGR state is stamped per column as a terminal stores it per cell, so a partial overwrite keeps each surviving character's own color: red `bad`, three backspaces, then `ok` shows `okd` with the `d` still red. A CSI sequence occupies no column and changes only the state later writes are stamped with, which is also why a carriage return does not reset color, and why SGR state threads from one line to the next rather than closing at each newline. Erase-in-line is part of the same replay, because `\r\x1b[K` is the single idiom every spinner and progress bar writes — modelling the `\r` alone left the previous frame's tail standing, which is text the terminal never showed. Only `m` accumulates into a cell's style; a cursor or erase sequence must not, or the state string grows per redraw and emits boundaries anser has to discard. SGR is held per cell as a NORMALIZED record (foreground, background, attribute set), not as the sequence history: accumulating raw sequences made every state boundary re-emit the whole chain, so output that switches color without a full reset emitted O(n^2) characters — 3200 such cells produced 25 MB and a `RangeError` well under bash's own output cap. The record also lets the attribute closers every chalk-based tool writes (`39`, `49`, `22`, `24`, …) actually close their attribute, and each boundary emits one canonical sequence for the state it opens. A run also has to CLOSE: the replay converges to the state the scan ended in, not the last written cell's, because a reset after the final write changes no cell yet ends the run — without that a line finishing in `\x1b[0m` leaked its color onto every later line. The cursor advances by terminal columns, so a tab reaches the next 8-column stop, a wide character takes two (its spacer blanking rather than closing the gap once the lead cell is overwritten), and a combining mark takes none. Width follows emoji PRESENTATION rather than the U+2600-U+27BF block: `\u2713`, the check every progress line writes, is one column, so treating the block as wide misaligned exactly the output this card exists for. Writing over either half of a wide pair blanks the other, since a terminal cannot leave one cell of a two-cell glyph standing: `a\tb` then a redraw of `XY` shows `XY b`, since a two-character redraw cannot reach column 8.
|
||||
- **Exit status and copy.** A non-zero exit code or a signal renders a status pill, matching the exit-status distinction the bash tool's own renderer draws; a clean exit renders none, and settled empty output renders a dimmed placeholder — judged on the parsed lines the card renders, not on the raw text, since output that is only escapes or control bytes survives a `trim()` yet parses to nothing visible and would otherwise draw blank rows plus a copy control for invisible bytes. The copy control copies the raw output text, not the rendered tree, so the prompt line and the pill stay out of the clipboard.
|
||||
|
||||
Geometry, radius, and fonts mirror `CodeBlock`, so a terminal card and a fenced code block match visually; `white-space: pre` plus horizontal scroll is the deliberate divergence. The clipboard write both components need moved out of `CodeBlock` into a package-internal `src/clipboard.ts`, unexported so it stays an implementation detail of the two blocks.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
The remaining bound: the row caps at `CHAT_TERMINAL_MAX_LINES` (8), half the primitive's default, which the panel keeps — the message flow is a summary surface read across many calls, the panel is the single-call reading surface. Only the terminal intent renders inline; a generic tool's content is still panel-only.
|
||||
|
||||
One premise of that split has since weakened: [tool rows stopped being details-panel click targets](2026-07-28-tool-call-file-open-in-os.md) and nothing replaced the gesture, so the panel is currently unreachable in the assembled application. The in-row cap is therefore the only surface a reader actually has for a long output, which the expand control covers. Restoring a panel entry point is that change's follow-up, not this one's — but until it lands, "the panel stays the place for the full output" is not true, and the row's expand control carries that load alone.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Render the terminal block only in the details panel.** This keeps the stated no-inline-output convention and needs no reversal to record. Rejected by the owner's explicit decision: a shell command's output is what the user came to read, and putting it one click away costs more than the convention buys. Recorded here as the owner's call, not as a conclusion derived from the codebase.
|
||||
|
||||
**Reuse `CodeBlock` with a `console` language instead of a new primitive.** Rejected: `CodeBlock` soft-wraps, which is the defect being fixed, and it has no exit status, no cwd prompt line, no height cap, and no ANSI handling. Adding four terminal-specific concerns to the shared code-fence component would impose them on every markdown fence. The two components share their geometry and font tokens instead, which is the only part where one implementation is correct for both.
|
||||
|
||||
**Hand-roll the SGR parser.** Rejected: an SGR parser is exactly the surface [prefer maintained dependencies over hand-rolling](../process/2026-07-26-dependencies-over-hand-rolling.md) says not to own — its edge cases (256-palette and truecolor forms, `reverse`, multi-parameter runs, unterminated sequences) each fail on output nobody produces in a test, so a hand-rolled version stays subtly wrong for a long time. Stated honestly against that policy's bar: `anser` does **not** delete existing owned code. It is a capability addition, which that note distinguishes from a net-deletion simplification; the health and boundary-fit halves of the bar are what it clears. What stays hand-rolled is the part `anser` does not cover: the theme-token color mapping, the non-CSI sanitizing, the carriage-return redraw, and the per-line span folding the height cap slices.
|
||||
|
||||
## Consequences
|
||||
|
||||
`anser` is a new runtime dependency of `packages/client/ui-primitives`, so every consumer of that package pays for it once. A bash row in the Web chat carries output, which is a deliberate density increase over a summary-only row; the cap is what keeps it bounded, and a tighter cap is a props change, not a redesign.
|
||||
|
||||
`TerminalBlock` reads only the terminal view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the terminal capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed.
|
||||
|
||||
A `run_code` sub-dispatch does not reach a terminal card on the shipped wire: `session.ts` folds `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and the host's `viewFor` presents only top-level `tool/call`/`tool/result`, so a nested bash call keeps the generic flattened form. Both arms are pinned — the resolution path with views injected, and the no-view shape the wire actually delivers — so the gap is recorded rather than implied. Carrying presenter views through the code-dispatch wire is that seam's own change.
|
||||
|
||||
Inline rendering is licensed for the terminal intent alone. A future intent that wants it needs its own bound and its own decision, argued against the reason recorded here rather than against the panel-only convention on its own.
|
||||
|
||||
## Testing
|
||||
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
`apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, the run-state dot resolves to the green success token rather than to a literal color (a `--dsw-*` var has no computed value at all without the real theme stylesheet) and sits inside the card box yet left of the prompt label, which is the invariant the gutter padding owns, and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `terminal-card.expected.md` golden records the resolved workspace in the prompt row, which is what a bash call with no `workdir` must show instead of a bare `$`.
|
||||
|
||||
## Related
|
||||
|
||||
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a full consumer of the `terminal` arm rather than of args alone.
|
||||
- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md) — owns `CodeBlock` and its shiki arm, and records why tool output deliberately stays unhighlighted; ANSI color here is authored color, not guessed grammar.
|
||||
- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Agent Note: Web terminal card — the bash render intent reaches the browser
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-web-terminal-card.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
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` 则把所有工具的内容块压平进一个 `<pre>`,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
|
||||
|
||||
## 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` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
|
||||
|
||||
该组件的契约:
|
||||
|
||||
- **提示符行,每条命令行一行。** 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的 `command` 就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。末尾换行是终止符,不是一条空的末命令。只有**第一行**携带该标签:视图只知道一个工作目录——调用开始处的那个——而后面的行完全可能在别处运行,命令里一个 `cd` 就足以改变它。把标签在各行重复,等于陈述一个此处无人知晓的逐行目录,这与运行状态点只出现一次是同一个理由。其余行保留一个裸 `$`,因此它们仍读作提示符。
|
||||
- **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片以**自身左内边距**预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。该预留用 padding 而非 margin,是因为每个渲染点都会整条重写 `margin` 来设定自己的缩进——那会静默取消基于 margin 的落区,并让容器把状态点裁掉。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。
|
||||
- **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。
|
||||
- **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。
|
||||
- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。光标移动在该剥除之前先行结算,且落在逐行的列缓冲里而不是靠字符串手术,因为回车与退格**只移动**光标——两者都不擦除任何东西,所以读者看到的就是每一列最后被写入的内容。`100%` 后接回车再接 `OK` 显示为 `OK0%`,因为这次重绘比它下面的帧更短;末尾 `abc` 加一个退格仍显示 `abc`,因为没有任何东西覆盖过那个 `c`;`abc` 加两个退格再接 `XY` 显示 `aXY`。这些用例都对照真实终端核实过,因为先前「截断加删除」的近似看起来是对的,实际并不对。SGR 状态按列打戳,与终端按单元格存储颜色的方式一致,因此部分覆盖会保留每个存活字符自身的颜色:红色 `bad`、三个退格、再写 `ok`,显示为 `okd` 且那个 `d` 仍是红的。CSI 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因,以及 SGR 状态会从一行延续到下一行、而不是在每个换行处关闭的原因。行内擦除属于同一次重放,因为 `\r\x1b[K` 是每个 spinner 与进度条都会写的同一个惯用法——只建模 `\r` 会让上一帧的尾巴留在原处,那是终端从未显示过的文本。只有 `m` 会累加进单元格样式;光标或擦除序列不能累加,否则状态串会随每次重绘线性增长,并发出 anser 只能丢弃的边界。SGR 按单元格以**归一化记录**保存(前景、背景、属性集合),而不是序列历史:累积原始序列会让每个状态边界重新发射整条链,因此不做完整 reset 的换色输出会发射 O(n^2) 个字符——3200 个这样的单元格产生 25 MB 并最终 `RangeError`,远低于 bash 自身的输出上限。该记录也让所有 chalk 系工具写出的属性闭合码(`39`、`49`、`22`、`24` 等)真正闭合其属性,且每个边界只为它开启的状态发射一条规范序列。一个分段也必须**收束**:重放收敛到扫描结束时的状态,而不是最后一个被写入单元格的状态——因为最后一次写入之后的 reset 不改变任何单元格,却结束了该分段;没有这一步,以 `\x1b[0m` 结尾的行会把颜色泄漏到其后所有行。光标按终端列推进,因此制表符前进到下一个 8 列制表位、宽字符占两列(其续列在首列被覆盖后变为空白而非合拢),组合标记不占列。宽度依据 emoji **presentation** 而非 U+2600–U+27BF 整个区块:`\u2713`——每条进度行都会写的对勾——只占一列,把该区块整体当作双宽恰好会错位这张卡片赖以存在的那类输出。写入宽字符对的任一半都会把另一半清成空白,因为终端无法让一个双格字形只留下一格:`a\tb` 之后用 `XY` 重绘显示为 `XY b`,因为两个字符的重绘到不了第 8 列。
|
||||
- **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字——该判定读的是卡片实际渲染的解析行,而非原始文本,因为只含转义或控制字节的输出能通过 `trim()` 却解析不出任何可见内容,否则就会画出一片空行外加一个把不可见字节写进剪贴板的复制控件。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。
|
||||
|
||||
几何尺寸、圆角与字体沿用 `CodeBlock`,因此终端卡片与围栏代码块在视觉上一致;`white-space: pre` 加横向滚动是有意的分歧。两个组件都需要的剪贴板写入从 `CodeBlock` 中提取到包内部的 `src/clipboard.ts`,不对外导出,因此它仍是这两个块的实现细节。
|
||||
|
||||
### 聊天行内嵌输出推翻了一条既有约定
|
||||
|
||||
`chat/ToolRow.tsx` 与 `contract/tool-call-model.ts` 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
|
||||
|
||||
这次推翻成立的理由:对 shell 命令而言,输出**就是**用户要读的结果,把它专门收进面板会让最常见的情形变成两步交互。行内一个有界、限高、不换行的终端块,正是让 bash 密集的 transcript 一遍读完的条件。旧规则真正担心的是行高不受输出长度约束,而高度上限加展开控件正是防止其复现的机制。
|
||||
|
||||
余下的约束:行内上限为 `CHAT_TERMINAL_MAX_LINES`(8),是组件默认值的一半,而面板沿用默认值——消息流是跨多次调用阅读的摘要表面,面板才是单次调用的阅读表面。只有 terminal 意图内嵌渲染;generic 工具的内容依旧只在面板中。
|
||||
|
||||
这一划分的一个前提此后被削弱了:[工具行已不再是详情面板的点击目标](2026-07-28-tool-call-file-open-in-os.md),且没有任何手势接替它,因此该面板在组装后的应用中当前不可达。于是行内上限成为读者实际拥有的唯一长输出表面,由展开控件承担。恢复面板入口属于那次改动的后续,而非本次改动——但在它落地之前,「面板仍是查看完整输出的地方」并不成立,行内的展开控件独自承担了这一职责。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只在详情面板渲染终端块。** 这样保留既有的「不内嵌输出」约定,也不需要记录任何推翻。已被 owner 的明确决定否决:shell 命令的输出正是用户来读的东西,把它挪到一次点击之外,代价高于该约定带来的收益。此处记录的是 owner 的裁决,而非从代码库推导出的结论。
|
||||
|
||||
**复用 `CodeBlock` 并传入 `console` 语言,而不新建组件。** 已否决:`CodeBlock` 会软换行,而软换行正是本次要修的缺陷,且它没有退出状态、没有 cwd 提示符行、没有高度上限、也不处理 ANSI。把四项终端专属关注点加进共享的代码围栏组件,等于把它们强加给每一个 markdown 围栏。两个组件改为共享几何与字体 token,那是唯一一处「一套实现对两者都正确」的部分。
|
||||
|
||||
**手写 SGR 解析器。** 已否决:SGR 解析器恰是[优先采用维护良好的依赖而非手写](../process/2026-07-26-dependencies-over-hand-rolling.md)所指明不该自持的那类实现——它的边界情形(256 色板与 truecolor 形式、`reverse`、多参数分段、未终止的序列)各自只在没人会写进测试的输出上失效,因此手写版本会在很长时间内一直微妙地出错。对照那条策略的门槛如实陈述:`anser` **并未**删除任何既有自持代码。它是一次能力增补,而那条 Agent Note 把这与净删除式的简化区分开来;它清过的是健康度与边界契合这两半门槛。`anser` 未覆盖而仍由我们手写的部分是:主题 token 的颜色映射、非 CSI 序列的剥除、回车重绘,以及供高度上限切片的逐行 span 折叠。
|
||||
|
||||
## Consequences
|
||||
|
||||
`anser` 成为 `packages/client/ui-primitives` 的一项新运行时依赖,因此该包的每个消费方都为它支付一次。Web 聊天中的 bash 行现在承载输出,相比只有摘要的行,这是有意提高的信息密度;上限是维持其有界的机制,而调紧上限是改一个 prop,不是重新设计。
|
||||
|
||||
`TerminalBlock` 只读取 terminal 视图携带的字段,因此它始终是渲染意图内容的纯函数——不查会话状态,与产出该视图的 presenter 一样可安全回放。不具备终端能力的 UI 仍从桥接层拿到围栏式回退;工具的结果形态未作任何改动。
|
||||
|
||||
在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 code-dispatch wire 属于那道接缝自身的改动。
|
||||
|
||||
内嵌渲染的许可仅授予 terminal 意图。将来想要内嵌的意图需要有自己的边界与自己的决定,且需针对此处记录的理由来论证,而不是仅针对「只在面板」这条约定本身。
|
||||
|
||||
## Testing
|
||||
|
||||
`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` 列表中,因此覆盖率运行不会统计其中任何文件。
|
||||
|
||||
`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 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。
|
||||
|
||||
`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,`--dsw-*` 变量根本不产生计算值),且它位于卡片盒之内、提示标签之左——这正是那道 gutter 内边距所拥有的不变量;复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `terminal-card.expected.md` 基准记录了提示行中已解析的 workspace——这正是不带 `workdir` 的 bash 调用应当显示的内容,而非一个裸 `$`。
|
||||
|
||||
## Related
|
||||
|
||||
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md)——本次消费的 `card` 标签词汇;Web client 现在是 `terminal` 分支的完整消费方,而不再只消费参数。
|
||||
- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md)——它拥有 `CodeBlock` 及其 shiki 分支,并记录了工具输出为何有意不做语法高亮;这里的 ANSI 颜色是作者指定的颜色,不是猜出来的语法。
|
||||
- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md)——两个渲染点所处的 slot 与快照分层。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
|
||||
2026-07-29-web-message-icon-actions-and-clock.md: e0072458e4c0d3e37998b5564ad14ce17aa41515
|
||||
2026-07-29-web-message-icon-actions-and-clock.zh.md: 1cc25a9656e7a100d78dd3b6b3675ca490f455f9
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Web message IconActions and clocks
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-web-message-icon-actions-and-clock.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The web chat user bubble already had copy / branch / edit IconActions but no clock. Finalized assistant narration had no under-body action chrome at all, even though the Harness design shows a copy / branch / clock row after the answer settles. Streaming replies must not flash that chrome mid-token. Memoized rows also keep stable props across midnight, so a one-shot `Date.now()` would leave yesterday's messages stuck on `HH:mm`.
|
||||
|
||||
## Decision
|
||||
|
||||
**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`; both seats re-format at the next local midnight.**
|
||||
|
||||
Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) and only when `streaming` is false with a known event time; the streaming tail omits the row. Copy writes joined text blocks. Branch stays a chrome stub. Hover-capable pointers keep both footers opacity-hidden until hover/focus-within. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Show assistant IconActions during streaming.** Rejected: the request is to reveal the row only after output completes; mid-stream chrome would flicker and invite copying a partial answer.
|
||||
|
||||
**Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat.
|
||||
|
||||
**Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source.
|
||||
|
||||
## Consequences
|
||||
|
||||
Settled assistant answers expose copy and the event clock immediately; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes and the midnight widen; the web e2e scenario pins the assembled IconActions chrome.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Web 消息 IconActions 与时钟
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-web-message-icon-actions-and-clock.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有时钟。已定稿的 assistant 叙述下方完全没有操作栏,尽管 Harness 设计稿在回答结束后展示复制/分支/时钟。流式回复不得在 token 中途闪出该栏。经 memo 的行在跨午夜时仍保持稳定 props,因此一次性的 `Date.now()` 会让昨日消息一直卡在 `HH:mm`。
|
||||
|
||||
## 决策
|
||||
|
||||
**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边都在下一个本地午夜重新格式化。**
|
||||
|
||||
两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。
|
||||
|
||||
## 曾考虑的方案
|
||||
|
||||
**在流式过程中展示 assistant IconActions。** 否决:需求是输出完成后才展示该行;中途 chrome 会闪烁,并诱使复制半截回答。
|
||||
|
||||
**把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。
|
||||
|
||||
**通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。
|
||||
|
||||
## 后果
|
||||
|
||||
已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态与午夜加宽;Web e2e 场景钉住组装后的 IconActions chrome。
|
||||
+2
-2
@@ -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: 63f25d5c36caecba435e9192534e0b494e1e0105
|
||||
2026-07-26-briefed-minimal-translation-updates.zh.md: 17cf2f895b187fb794e691c2b14d6e0bff78363d
|
||||
2026-07-26-briefed-minimal-translation-updates.md: a47251376771165d0eb229aaa0fb7f63589d7d77
|
||||
2026-07-26-briefed-minimal-translation-updates.zh.md: 5a6787b9bdb3286b71842abec075c5a0ebfc7991
|
||||
@@ -14,7 +14,7 @@ Pair updates run on a generated briefing instead of the guidance corpus; only ne
|
||||
|
||||
- **`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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -40,6 +40,7 @@ A second head-to-head replay on the same ten examples compared this note's shipp
|
||||
|
||||
- A small prose edit's counterpart update now costs a briefing generation plus one small focused task — no corpus reads, no archaeology, no corpus-wide scans inside the loop — and the same PR obligation holds; the cheap path and the correct path point the same way.
|
||||
- The briefing generator is a second consumer of the consistency records: recorded blob hashes now also drive diff recovery and section mapping, strengthening the incentive to keep records honest.
|
||||
- Each distinct confirmed snapshot retains a content-addressed local ref and object. An abandoned re-record may therefore leave an extra durable pin, but it changes no branch or commit history; this local retention is the tradeoff that prevents garbage collection from invalidating an accepted pairing record.
|
||||
- `--write` without arguments no longer works; muscle-memory callers must name pairs or pass `--all`. That is the point — the bulk bless is now a visible, deliberate act.
|
||||
- Scoped checks mean an update loop can be green while an unrelated pair elsewhere is red; the corpus-wide check in `doc-sync`/CI still owns the tree-level invariant.
|
||||
- Span mapping trusts an alignment only when the kind sequences match across the last-confirmed source, current source, and current counterpart; a mapping failure widens deterministically (units → sections → whole document) rather than guessing, so a restructured document gets an explicit "locate the regions yourself" briefing, never a wrong map.
|
||||
@@ -47,4 +48,4 @@ A second head-to-head replay on the same ten examples compared this note's shipp
|
||||
|
||||
## Testing
|
||||
|
||||
[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) pins unit and section span extraction (container-scoped kinds, depth-only section alignment so translated heading text still maps, preamble), alignment and changed-index detection, the mechanical code splice and each of its refusal conditions, terminology row matching in both directions with word-boundary and plural-inflection discipline, first-occurrence movement tracking, fence escalation, and the rendered briefing's contract (unit bundles with three-way context, mechanical/sections/document scopes, per-direction digests, scoped finish commands). [scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) pins argument normalization (any pair file or bare stem to the anchor) and the CLI matrix: scoped check, bare `--write` refusal, `--write <pair>`, `--write --all`, `--list` exclusivity, unknown flags.
|
||||
[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) pins unit and section span extraction (container-scoped kinds, depth-only section alignment so translated heading text still maps, preamble), alignment and changed-index detection, the mechanical code splice and each of its refusal conditions, terminology row matching in both directions with word-boundary and plural-inflection discipline, first-occurrence movement tracking, fence escalation, and the rendered briefing's contract (unit bundles with three-way context, mechanical/sections/document scopes, per-direction digests, scoped finish commands). [scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) pins exact-byte persistence and recovery for uncommitted snapshots, failure before an unavailable object can enter a sidecar, argument normalization (any pair file or bare stem to the anchor), and the CLI matrix: scoped check, bare `--write` refusal, `--write <pair>`, `--write --all`, `--list` exclusivity, unknown flags.
|
||||
+3
-2
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
- **`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)中的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。
|
||||
- **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。
|
||||
- **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。
|
||||
- **配对门禁接受配对参数。**`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 名称或暴露于垃圾回收。
|
||||
|
||||
## 基准测试
|
||||
|
||||
@@ -40,6 +40,7 @@ Status: implemented
|
||||
|
||||
- 一次小的行文修改,其对侧更新如今只需生成一份简报,外加一个小而聚焦的任务(不读指导语料、不翻查历史、循环内不做全语料扫描),同一 PR 内完成更新的义务保持不变;低成本的路径与正确的路径指向同一个方向。
|
||||
- 简报生成器成为一致性记录的第二个消费方:记录的 blob hash 如今还驱动 diff 还原与章节映射,这进一步强化了如实维护记录的动机。
|
||||
- 每个不同的已确认快照都会保留一个内容寻址的本地 ref 和对象。因此,中途放弃的重新记录可能留下额外的持久固定项,但它不会改变任何分支或提交历史;这种本地保留正是防止垃圾回收让已接受配对记录失效所付出的代价。
|
||||
- 不带参数的 `--write` 不再可用;靠肌肉记忆的调用者必须点名配对或传 `--all`。这正是目的所在:批量背书如今是一个可见的、有意为之的动作。
|
||||
- 按对检查意味着一个更新循环可以在别处某个无关配对处于红灯时自己保持绿灯;`doc-sync`/CI 中的全语料检查仍然承载树级不变式。
|
||||
- 区间映射只在上次确认源文、当前源文与当前对侧文本三方的种类序列一致时才信任一处对齐;映射失败时粒度确定性地逐级放宽(单元 → 章节 → 整篇文档)而不是靠猜,因此被重构过的文档拿到的是一份明确写着「请自行定位相关区域」的简报,绝不会是一张错误的地图。
|
||||
@@ -47,4 +48,4 @@ Status: implemented
|
||||
|
||||
## 测试
|
||||
|
||||
[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) 固定单元与章节的区间提取(以容器为作用域的种类、只按深度对齐章节从而让已翻译的标题文字仍能映射、首个标题前的序言)、对齐与改动索引检测、机械代码拼接及其每一个拒绝条件、带词边界与复数变形约束的双向术语行匹配、首次出现移位跟踪、围栏升级,以及渲染后简报的契约(带三方上下文的单元条目、机械/章节/整篇文档三种范围、分方向的规则摘要、按对的收尾命令)。[scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) 固定参数归一化(配对的任一文件或裸词干都归一到锚点)与 CLI(命令行界面)用例矩阵:按对检查、裸 `--write` 拒绝执行、`--write <pair>`、`--write --all`、`--list` 的互斥性、未知标志。
|
||||
[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) 固定单元与章节的区间提取(以容器为作用域的种类、只按深度对齐章节从而让已翻译的标题文字仍能映射、首个标题前的序言)、对齐与改动索引检测、机械代码拼接及其每一个拒绝条件、带词边界与复数变形约束的双向术语行匹配、首次出现移位跟踪、围栏升级,以及渲染后简报的契约(带三方上下文的单元条目、机械/章节/整篇文档三种范围、分方向的规则摘要、按对的收尾命令)。[scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) 固定未提交快照的精确字节持久化与取回、在不可用对象进入伴随记录前失败、参数归一化(配对的任一文件或裸词干都归一到锚点),以及 CLI(命令行界面)用例矩阵:按对检查、裸 `--write` 拒绝执行、`--write <pair>`、`--write --all`、`--list` 的互斥性、未知标志。
|
||||
+3
-3
@@ -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-26-web-syntax-highlighting-shiki.md: b329e35f1d0ce7b3de454758403a09f67056b5af
|
||||
2026-07-26-web-syntax-highlighting-shiki.zh.md: 8e9d1f0d0c38ce64bcb5da1262538da762f70b12
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
|
||||
2026-07-26-web-syntax-highlighting-shiki.md: 48a1e4c43f19693f90906f210f0ed85db3f31687
|
||||
2026-07-26-web-syntax-highlighting-shiki.zh.md: 780b66a309c841c542f873f376226b8da454e050
|
||||
@@ -17,7 +17,7 @@ The client rendered every code surface — markdown fences in assistant prose, t
|
||||
- **Dependency**: `shiki/core` + `@shikijs/langs`, composed via `createHighlighterCoreSync` with `createJavaScriptRegexEngine({ forgiving: true })` — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: `typescript` (embeds JS), `shellscript`, `json` — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here.
|
||||
- **Singleton**: `ui-primitives/src/markdown/highlight.ts` creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). Engine + grammar construction is a ~120-175ms long task, so the module pre-warms the singleton in a deferred task at plugin boot (the lazy path stays as the correctness fallback), keeping the cost off the render path where a stream's finalize swap would jank. The alias table is a `Map`, not an object: fence info strings are assistant-authored, so a label like `constructor` must miss instead of resolving an inherited property and crashing shiki. The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path.
|
||||
- **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in a new `ui-theme/styles/shiki.css` token sheet (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by the shell's `base.css` chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree.
|
||||
- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps.
|
||||
- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Tool output is never syntax-highlighted — it is arbitrary text, and guessing a grammar would mis-highlight more than it helps; a bash card's output carries only the color its own ANSI sequences declare, through [the terminal card](../feature/2026-07-28-web-terminal-card.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围
|
||||
- **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。
|
||||
- **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
|
||||
- **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
|
||||
- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。
|
||||
- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。工具输出从不做语法高亮——它是任意文本,硬猜一种语法,带来的误高亮会多于帮助;bash 卡片的输出只承载其自身 ANSI 序列声明的颜色,经由[终端卡片](../feature/2026-07-28-web-terminal-card.md)渲染。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -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-worktree-local-lefthook.md
|
||||
2026-07-27-worktree-local-lefthook.md: d18f6c1bf8fe240759ad48f67ca6b231000eaf2c
|
||||
2026-07-27-worktree-local-lefthook.zh.md: 42a1625a3b2ec7b00942dc46b0c9c64058ecd2fc
|
||||
2026-07-27-worktree-local-lefthook.md: 75dfd47087356c34005ec4673e174e451a72c660
|
||||
2026-07-27-worktree-local-lefthook.zh.md: bc4902769561c3d33d2101de55e28e70d114f39b
|
||||
@@ -16,9 +16,9 @@ Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, t
|
||||
|
||||
Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking.
|
||||
|
||||
Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
|
||||
Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Git seeds a new linked worktree's `config.worktree` from the main worktree; when that seed contains the marker-backed reserved hook path of a registered worktree, the installer replaces only the new worktree's config with its own path. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
|
||||
|
||||
If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback.
|
||||
If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, copied new-worktree configuration, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝
|
||||
|
||||
升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空且尚未生效的 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须手动恢复,不会被自动破坏。
|
||||
|
||||
每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。
|
||||
每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Git 会以主 worktree 的配置为新链接 worktree 初始化 `config.worktree`;当该初始配置包含某个已注册 worktree 中由所有权标记佐证的保留钩子路径时,安装程序只会在新 worktree 的配置中将其替换为新 worktree 自有的路径。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。
|
||||
|
||||
若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。
|
||||
若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、复制的新 worktree 配置、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.md
|
||||
2026-07-28-local-json-tree-renderer.md: 5e8e6d6968319ada53e65a48e9e03b297dc0141c
|
||||
2026-07-28-local-json-tree-renderer.zh.md: 221880391da05551d05ad9e3dadb909e2e74fa68
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Local JSON tree renderer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-local-json-tree-renderer.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The read-only JSON inspector used by the [trajectory ledger](../feature/2026-07-27-trajectory-inspection-ledger.md) needs compact object and array previews, explicit array paths for copy actions, fixed-open and collapsible root modes, and keyboard navigation. `react-json-view-lite` exposes neither custom node rendering nor row identity, so satisfying those requirements through that dependency requires a package-manager patch against compiled distribution files and DOM traversal that reconstructs data paths from visible labels. The patch behaves as an untyped fork while its source maps and upstream source remain unchanged.
|
||||
|
||||
## Decision
|
||||
|
||||
`JsonTree` owns its recursive presentation in `dsh-client-ui-primitives`.
|
||||
|
||||
- Each rendered row receives its value and property path directly. Object keys and array indexes extend that path during recursion, so copy actions never recover application data from rendered DOM text.
|
||||
- Expandable rows render the compact preview locally and mount child rows only while expanded. `expandTopLevel` selects between a fixed-open bracket frame and a collapsible root node without changing the public component contract.
|
||||
- The tree keeps one tabbable expander among visible nodes. Pointer activation claims that tab stop; Up and Down move it cyclically, while Left and Right collapse or expand the focused node.
|
||||
- `react-json-view-lite` is not a package dependency and has no pnpm patch. Focused component tests pin previews, expansion, keyboard focus, and array copy paths.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the distribution patch.** Rejected because the application-specific renderer and array identity contract would remain hidden in generated third-party files, and every dependency update would require reviewing a fork without matching source maps.
|
||||
|
||||
**Use the upstream renderer without previews.** Rejected because `{…}` and `[…]` discard the compact payload context that the trajectory inspector uses for scanning.
|
||||
|
||||
**Inject previews and row metadata after render.** Rejected because effects or mutation observers would depend on the same private DOM structure while splitting one row between React ownership and imperative mutation.
|
||||
|
||||
**Adopt a larger JSON viewer.** Rejected because editing, search, and theme systems are outside the current read-only contract; the added dependency surface would not remove the inspector-specific copy and layout code.
|
||||
|
||||
## Consequences
|
||||
|
||||
The JSON inspector has one source-level owner, explicit data flow, accurate array paths, and no patched dependency. The package now owns recursive rendering, expansion state, ARIA tree structure, and roving focus behavior, so changes to those semantics require focused component coverage. The implementation remains intentionally read-only and limited to the preview, navigation, and copy behavior used by current consumers.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note:本地 JSON 树渲染器
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-local-json-tree-renderer.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[轨迹检查记录表](../feature/2026-07-27-trajectory-inspection-ledger.md)使用的只读 JSON 检查器需要提供紧凑的对象和数组预览、供复制操作使用的明确数组路径、固定展开与可折叠两种根节点模式,以及键盘导航。`react-json-view-lite` 既不提供自定义节点渲染,也不提供行标识;要通过该依赖满足这些要求,就必须使用包管理器为编译后的发布文件打补丁,并遍历 DOM,从可见标签中还原数据路径。该补丁实际上相当于一个不受类型约束的 fork,但其源码映射与上游源码均未同步修改。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-client-ui-primitives` 中的 `JsonTree` 自行负责递归呈现。
|
||||
|
||||
- 每个渲染行都直接接收自身的值和属性路径。递归时,对象键和数组索引会附加到路径末尾,因此复制操作无需再从 DOM 渲染文本中反向还原应用数据。
|
||||
- 可展开行在本地渲染紧凑预览,仅在展开时挂载子行。`expandTopLevel` 可选择固定展开的括号框架或可折叠根节点,而不改变组件的公开契约。
|
||||
- 树中所有可见节点仅保留一个可通过 Tab 键聚焦的展开控件。使用指针激活控件后,该控件会成为 Tab 键焦点位置;上、下方向键循环移动焦点,左、右方向键折叠或展开当前焦点所在节点。
|
||||
- `react-json-view-lite` 不在 `dsh-client-ui-primitives` 的依赖项中,也没有 pnpm 补丁。针对性组件测试锁定预览、展开、键盘焦点和数组复制路径。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留发布文件补丁。** 不予采纳:应用专用的渲染逻辑和数组标识契约仍会隐藏在生成的第三方文件中;每次更新依赖,都必须评审一个没有配套源码映射的 fork。
|
||||
|
||||
**直接使用不提供预览的上游渲染器。** 不予采纳:`{…}` 和 `[…]` 会丢失轨迹检查器扫读时所需的紧凑载荷上下文。
|
||||
|
||||
**渲染后注入预览和行元数据。** 不予采纳:Effect 或 MutationObserver 仍会依赖同一套私有 DOM 结构,同时使 React 和命令式变更机制分别负责同一行的不同部分。
|
||||
|
||||
**采用更大型的 JSON 查看器。** 不予采纳:编辑、搜索与主题系统不在当前只读契约范围内;扩大依赖范围也无法去除检查器专用的复制和布局代码。
|
||||
|
||||
## 后果
|
||||
|
||||
JSON 检查器在源码层由单一实现负责,具有显式数据流和准确的数组路径,且没有经过补丁修改的依赖。`dsh-client-ui-primitives` 负责递归渲染、展开状态、ARIA 树结构和焦点循环移动行为,因此修改这些语义时必须提供针对性组件测试。实现有意保持只读,仅包含当前消费方使用的预览、导航与复制行为。
|
||||
@@ -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-07-24-web-gui-browser-e2e-lane.md
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: d9e0a9660ecd6aeb75e835e68f92c0a268423872
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: e8c7d1c4596f20d88bd08423549fb6a9f7b0654b
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: ce59dcce270d548c91e3719eee8e9c83aea0c154
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: bad3dd15ed7b98cc17340666a6c1094d0de057b1
|
||||
@@ -42,7 +42,7 @@ The typecheck plane split is structural: the host scaffold, its support module,
|
||||
|
||||
### Coverage contract
|
||||
|
||||
The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout persistence, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown.
|
||||
The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown.
|
||||
|
||||
### CI stance
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
### 覆盖契约
|
||||
|
||||
该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局持久化、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。
|
||||
该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。
|
||||
|
||||
### CI 立场
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
|
||||
2026-07-04-prune-dead-core-spine-surface.md: a6c608617415f3af07de5c95fd20b0bde40bdef3
|
||||
2026-07-04-prune-dead-core-spine-surface.zh.md: 83603d8a8432b99d8f42222442b38005e196ac4e
|
||||
2026-07-04-prune-dead-core-spine-surface.md: 473f655ab0944b43f9b1193413801eb7a80286d8
|
||||
2026-07-04-prune-dead-core-spine-surface.zh.md: 00c6f7acca403c937ff765b6d73d3d0fa0c3153a
|
||||
@@ -19,7 +19,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime
|
||||
| `code-runtime-worker` protocol/bootstrap re-exports | Outside-package production/e2e consumers use `WorkerCodeRuntime` and config, not `BootstrapPort`, `PatchableStream`, or worker message/boot types. | Keep the runtime class/config contract and make its wire/bootstrap vocabulary source-private. |
|
||||
| ACP `agentOptions` root export | The helper has only same-file and ACP-test consumers; the sole outside-package production consumer mounts the plugin namespace. | Keep `name`, `inject`, `Config`, `AcpConfig`, and `apply`; make `agentOptions` source-private and test it through bridge behavior. |
|
||||
| `providerWording` and `completedTurnPrefix` root exports | Each has one same-package production caller; only the balanced-prefix helper has a same-package white-box test. | Make them source-private and test provider behavior. |
|
||||
| `depthOf`, `SubagentDepthError`, `SENSITIVE_ENV_PATTERN`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. | Keep depth/environment/exit behavior but make the helpers and error/regex source-private; test through spawn and disposal. |
|
||||
| `depthOf`, `SubagentDepthError`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. `SENSITIVE_ENV_PATTERN` is excluded because the SDK helper applies it to caller-supplied environments. | Keep depth and exit behavior but make the remaining helpers and error source-private; test through spawn and disposal. Keep the shared credential pattern public. |
|
||||
| `PersistenceCoordinator.inits`, backend `inits` accessors, `seedCoversPrefix`, and `assertSerializable` | The accessors exist for white-box tests; `seedCoversPrefix` has no outside production importer; `assertSerializable` has no production caller and duplicates the coordinator append boundary's lossless snapshot. | Observe initialization through `session/flush`, make `seedCoversPrefix` source-private, and delete `assertSerializable`. Keep both backends, `SessionHeader`, and SQLite's version contract. |
|
||||
| `LlmError.status` and replay status | Adapters/replay populate it, but production branches on stable error code/message and never reads raw status. | Remove the unread field and replay plumbing while preserving error classification. |
|
||||
| `BlockAssembler.push()` return value | Both production callers ignore the returned completed block. | Return `void`; keep the deliberately public `blocks()`/`message()` contract. |
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Status: proposed
|
||||
| `code-runtime-worker` 的 protocol/bootstrap 再导出 | 包外的生产/e2e 消费方使用 `WorkerCodeRuntime` 和配置,而非 `BootstrapPort`、`PatchableStream` 或 worker 消息/启动类型。 | 保留运行时类/配置契约,将其协议格式/bootstrap 词汇改为源码私有。 |
|
||||
| ACP 的 `agentOptions` 根导出 | 该辅助函数只有同文件和 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 `agentOptions` 改为源码私有,通过桥接层行为测试。 |
|
||||
| `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试提供方行为。 |
|
||||
| `depthOf`、`SubagentDepthError`、`SENSITIVE_ENV_PATTERN`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。 | 保留深度/环境/退出行为,但将辅助函数和 error/regex 改为源码私有;通过 spawn 和 dispose 测试。 |
|
||||
| `depthOf`、`SubagentDepthError`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。`SENSITIVE_ENV_PATTERN` 不在其中,因为 SDK helper 会将它应用于调用方传入的环境。 | 保留深度与退出行为,但将剩余辅助函数和 error 改为源码私有;通过 spawn 和 dispose 测试。保持共享凭据正则公开。 |
|
||||
| `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本契约。 |
|
||||
| `LlmError.status` 与回放 status | 适配器/回放填充它,但生产分支基于稳定的错误码/消息判断,从不读取原始 status。 | 移除未读字段和回放管道,保留错误分类。 |
|
||||
| `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 契约。 |
|
||||
|
||||
@@ -89,7 +89,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
||||
## Conventions
|
||||
|
||||
- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
|
||||
- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). CLI source-launch code and every module it reaches must support Node `--experimental-transform-types`: use `import type` for erased bindings and native ESM exports, with no TSX/JSX or tsx/esbuild-only transforms. TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md).
|
||||
- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
|
||||
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
|
||||
- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)).
|
||||
- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.
|
||||
|
||||
@@ -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: 6b3a31a30a941e67341a518a7e32b1bf99eee8b0
|
||||
README.zh.md: 133c721612012b8fd1327344ae4e2951d05047cb
|
||||
README.md: 93c36d18abd06bbd7a80c918f520b92489180395
|
||||
README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd
|
||||
+1
-1
@@ -28,6 +28,6 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr
|
||||
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
|
||||
```
|
||||
|
||||
Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. Every module reachable from the CLI source entry follows Node's transform-types contract: erased bindings use `import type`, exports use native ESM, and the graph contains no TSX/JSX or transforms that only tsx/esbuild provides. The loader reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. It maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
|
||||
Source launches run `apps/cli/src/bin.ts` through tsx's ESM-only hook (`node --import tsx/esm`), which transforms TypeScript and projects the root tsconfig `paths` map into module resolution. Node's native TypeScript modes are not used: Node 26 removed `--experimental-transform-types`, and strip-only mode rejects syntax the source graph relies on (vendored parameter properties, decorators, runtime enums/namespaces). The CJS hook stays off because the source graph is ESM-only and the CJS resolver adds ~0.4s of startup. `bin/dsh` pins `TSX_TSCONFIG_PATH` to the checkout's root tsconfig so resolution is cwd-independent, and the `dsh-source-launch-smoke` node-compat gate runs this exact launch vector on every supported Node line. tsx applies the `paths` map without checking dependency declarations, so declaration completeness rests on the static gates: the TUI configs resolve bare plugins through `examples/package.json`, the Web/headless `cordis.yml` through this package's `dependencies`, and `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
|
||||
|
||||
`pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.
|
||||
@@ -28,6 +28,6 @@ Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将
|
||||
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
|
||||
```
|
||||
|
||||
源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。从 CLI 源码入口可达的每个模块都遵守 Node transform-types 契约:会被擦除的绑定使用 `import type`,export 使用原生 ESM,整个依赖图不含 TSX/JSX,也不依赖仅由 tsx/esbuild 提供的转换。设置 `TSX_TSCONFIG_PATH` 时,loader 会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web/无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
|
||||
源码启动会通过 tsx 的 ESM-only hook(`node --import tsx/esm`)运行 `apps/cli/src/bin.ts`,由它转换 TypeScript 并将根 tsconfig 的 `paths` 映射投射到模块解析中。不使用 Node 原生 TypeScript 模式:Node 26 移除了 `--experimental-transform-types`,而 strip-only 模式无法接受源码图依赖的语法(vendor 中的参数属性、装饰器、运行时 enum/namespace)。CJS hook 保持关闭,因为源码图是纯 ESM,而 CJS 解析器会增加约 0.4s 启动耗时。`bin/dsh` 将 `TSX_TSCONFIG_PATH` 固定到 checkout 的根 tsconfig,使解析与 cwd 无关;node-compat 门禁 `dsh-source-launch-smoke` 会在每条受支持的 Node 版本线上运行这一精确启动向量。tsx 应用 `paths` 映射时不检查依赖声明,声明完整性由静态门禁保障:TUI 配置通过 `examples/package.json` 解析裸插件,Web/无头 `cordis.yml` 通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
|
||||
|
||||
`pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* Node module resolve hook for the `dsh` source launcher. It projects the root
|
||||
* tsconfig `paths` map into Node resolution while leaving all TypeScript syntax
|
||||
* handling to Node's native transform-types runtime.
|
||||
* @module @deepseek-ai/dsh/tsconfig-paths-loader
|
||||
*/
|
||||
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, extname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import type { ResolveHookContext, ResolveFnOutput } from 'node:module'
|
||||
import ts from 'typescript'
|
||||
|
||||
interface LoaderData {
|
||||
tsconfigPath: string
|
||||
}
|
||||
|
||||
interface PackageManifest {
|
||||
name?: string
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
interface PathRule {
|
||||
pattern: string
|
||||
prefix: string
|
||||
suffix: string
|
||||
targets: readonly string[]
|
||||
}
|
||||
|
||||
interface PathsCompilerOptions {
|
||||
readonly baseUrl?: string
|
||||
readonly paths?: ts.MapLike<string[]>
|
||||
readonly pathsBasePath?: string
|
||||
}
|
||||
|
||||
// Node's native TypeScript transform cannot parse JSX, so `.tsx` is excluded.
|
||||
const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const
|
||||
|
||||
/**
|
||||
* Resolve package imports through one parsed tsconfig paths table.
|
||||
*
|
||||
* Manifest reads are process-scoped and memoized by path. Only matched source
|
||||
* aliases enter the cache, bounding it to directories participating in source
|
||||
* resolution.
|
||||
*/
|
||||
export class TsconfigPathsResolver {
|
||||
private readonly rules: readonly PathRule[]
|
||||
private readonly configDirectory: string
|
||||
private readonly manifests = new Map<string, Promise<PackageManifest | undefined>>()
|
||||
|
||||
private constructor(configDirectory: string, paths: ts.MapLike<string[]>) {
|
||||
this.configDirectory = configDirectory
|
||||
this.rules = Object.entries(paths)
|
||||
.map(([pattern, targets]) => {
|
||||
const wildcard = pattern.indexOf('*')
|
||||
return {
|
||||
pattern,
|
||||
prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard),
|
||||
suffix: wildcard === -1 ? '' : pattern.slice(wildcard + 1),
|
||||
targets,
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftExact = left.pattern.includes('*') ? 0 : 1
|
||||
const rightExact = right.pattern.includes('*') ? 0 : 1
|
||||
return rightExact - leftExact || right.prefix.length - left.prefix.length || right.suffix.length - left.suffix.length
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a tsconfig including its `extends` chain.
|
||||
* @param tsconfigPath Absolute tsconfig path supplying `compilerOptions.paths`.
|
||||
* @returns A resolver backed by that path table.
|
||||
*/
|
||||
static create(tsconfigPath: string): TsconfigPathsResolver {
|
||||
let unrecoverable: ts.Diagnostic | undefined
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, {
|
||||
...ts.sys,
|
||||
onUnRecoverableConfigFileDiagnostic(diagnostic) { unrecoverable = diagnostic },
|
||||
})
|
||||
if (parsed === undefined) {
|
||||
const detail = unrecoverable === undefined
|
||||
? 'unknown configuration error'
|
||||
: ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n')
|
||||
throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`)
|
||||
}
|
||||
const options = parsed.options as PathsCompilerOptions
|
||||
const paths = options.paths
|
||||
if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`)
|
||||
const configDirectory = options.baseUrl ?? options.pathsBasePath ?? dirname(tsconfigPath)
|
||||
return new TsconfigPathsResolver(configDirectory, paths)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one bare package specifier to a source file when the importing
|
||||
* package (or config-directory owner) declares that package at runtime.
|
||||
* @param specifier Module specifier passed to Node.
|
||||
* @param parentURL Importing file or Loader config-directory URL.
|
||||
* @returns Source file URL, or `undefined` when normal Node resolution owns the request.
|
||||
*/
|
||||
async resolve(specifier: string, parentURL: string | undefined): Promise<string | undefined> {
|
||||
const packageName = packageNameFromSpecifier(specifier)
|
||||
if (packageName === undefined || parentURL === undefined || !parentURL.startsWith('file:')) return undefined
|
||||
const matched = this.match(specifier)
|
||||
if (matched === undefined) return undefined
|
||||
const configParent = parentURL.endsWith('/')
|
||||
const parentPath = fileURLToPath(parentURL)
|
||||
const startDirectory = configParent ? parentPath : dirname(parentPath)
|
||||
if (!await this.isDeclaredRuntimeDependency(startDirectory, packageName, configParent)) return undefined
|
||||
|
||||
for (const target of matched.targets) {
|
||||
const substituted = target.replace('*', matched.wildcard)
|
||||
const candidate = await existingSourcePath(resolve(this.configDirectory, substituted))
|
||||
if (candidate !== undefined) return pathToFileURL(candidate).href
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private match(specifier: string): { targets: readonly string[]; wildcard: string } | undefined {
|
||||
for (const rule of this.rules) {
|
||||
if (!rule.pattern.includes('*')) {
|
||||
if (specifier === rule.pattern) return { targets: rule.targets, wildcard: '' }
|
||||
continue
|
||||
}
|
||||
if (!specifier.startsWith(rule.prefix) || !specifier.endsWith(rule.suffix)) continue
|
||||
const wildcard = specifier.slice(rule.prefix.length, specifier.length - rule.suffix.length)
|
||||
return { targets: rule.targets, wildcard }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async isDeclaredRuntimeDependency(
|
||||
startDirectory: string,
|
||||
packageName: string,
|
||||
searchAncestors: boolean,
|
||||
): Promise<boolean> {
|
||||
for (let directory = startDirectory; ; directory = dirname(directory)) {
|
||||
const manifest = await this.readManifest(join(directory, 'package.json'))
|
||||
if (manifest !== undefined) {
|
||||
if (declaresRuntimeDependency(manifest, packageName)) return true
|
||||
if (!searchAncestors) return false
|
||||
}
|
||||
const parent = dirname(directory)
|
||||
if (parent === directory) return false
|
||||
}
|
||||
}
|
||||
|
||||
private readManifest(path: string): Promise<PackageManifest | undefined> {
|
||||
let pending = this.manifests.get(path)
|
||||
if (pending !== undefined) return pending
|
||||
pending = readFile(path, 'utf8').then(
|
||||
content => JSON.parse(content) as PackageManifest,
|
||||
(error: unknown) => {
|
||||
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
||||
throw error
|
||||
},
|
||||
)
|
||||
this.manifests.set(path, pending)
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
let resolver: TsconfigPathsResolver | undefined
|
||||
|
||||
/** Initialize the hook worker from the source-launch preloader. */
|
||||
export function initialize(data: LoaderData): void {
|
||||
resolver = TsconfigPathsResolver.create(data.tsconfigPath)
|
||||
}
|
||||
|
||||
/** Resolve declared workspace packages to source and delegate every other request to Node. */
|
||||
export async function resolveHook(
|
||||
specifier: string,
|
||||
context: ResolveHookContext,
|
||||
nextResolve: (specifier: string, context: ResolveHookContext) => Promise<ResolveFnOutput>,
|
||||
): Promise<ResolveFnOutput> {
|
||||
const url = await resolver?.resolve(specifier, context.parentURL)
|
||||
return url === undefined ? nextResolve(specifier, context) : { url, shortCircuit: true }
|
||||
}
|
||||
|
||||
// Node customization hooks discover this exact export name.
|
||||
export { resolveHook as resolve }
|
||||
|
||||
function packageNameFromSpecifier(specifier: string): string | undefined {
|
||||
if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) {
|
||||
return undefined
|
||||
}
|
||||
const segments = specifier.split('/')
|
||||
return specifier.startsWith('@')
|
||||
? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
|
||||
: segments[0] || undefined
|
||||
}
|
||||
|
||||
function declaresRuntimeDependency(manifest: PackageManifest, packageName: string): boolean {
|
||||
return manifest.name === packageName
|
||||
|| packageName in (manifest.dependencies ?? {})
|
||||
|| packageName in (manifest.optionalDependencies ?? {})
|
||||
|| packageName in (manifest.peerDependencies ?? {})
|
||||
}
|
||||
|
||||
async function existingSourcePath(base: string): Promise<string | undefined> {
|
||||
const extension = extname(base)
|
||||
if (extension === '.tsx') return undefined
|
||||
const candidates = extension === ''
|
||||
? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))]
|
||||
: [base]
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if ((await stat(candidate)).isFile()) return candidate
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts`
|
||||
* with the exact production launch vector (`node --import tsx/esm`, the same
|
||||
* shape as `bin/dsh` and the root `dsh`/`demo:tui`/`demo:web` scripts) and
|
||||
* assert the piped-stdio TTY refusal. The Node compatibility matrix runs this
|
||||
* WHOLE file, so a Node release changing module hooks or TypeScript handling
|
||||
* breaks this gate instead of every developer's `pnpm dsh`; the built-bin
|
||||
* suite covers the published `lib/` entry, not this source chain.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshSourceBin = 'apps/cli/src/bin.ts'
|
||||
|
||||
describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
|
||||
it('boots the source entry and refuses pipes LOUD (non-zero exit + stderr)', async () => {
|
||||
const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], {
|
||||
cwd: repoRoot,
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh source launch did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toContain('requires stdin and stdout to be interactive TTYs')
|
||||
expect(result.stderr).toContain('dsh -p')
|
||||
// The refusal happens before any plugin mounts: stdout stays silent.
|
||||
expect(result.stdout).toBe('')
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -1,180 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type { ResolveFnOutput, ResolveHookContext } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { initialize, resolveHook, TsconfigPathsResolver } from '../src/tsconfig-paths-loader.ts'
|
||||
|
||||
class ResolverFixture {
|
||||
readonly root = mkdtempSync(join(tmpdir(), 'dsh-tsconfig-paths-'))
|
||||
|
||||
path(relativePath: string): string {
|
||||
return join(this.root, relativePath)
|
||||
}
|
||||
|
||||
write(relativePath: string, content = 'export {}\n'): string {
|
||||
const path = this.path(relativePath)
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content)
|
||||
return path
|
||||
}
|
||||
|
||||
writeJson(relativePath: string, value: unknown): string {
|
||||
return this.write(relativePath, `${JSON.stringify(value)}\n`)
|
||||
}
|
||||
|
||||
createResolver(paths: Record<string, string[]>): TsconfigPathsResolver {
|
||||
const tsconfigPath = this.writeJson('tsconfig.json', { compilerOptions: { paths } })
|
||||
return TsconfigPathsResolver.create(tsconfigPath)
|
||||
}
|
||||
|
||||
parentURL(relativePath = 'consumer/src/nested/index.ts'): string {
|
||||
return pathToFileURL(this.path(relativePath)).href
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
rmSync(this.root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const fixtures: ResolverFixture[] = []
|
||||
|
||||
function fixture(): ResolverFixture {
|
||||
const value = new ResolverFixture()
|
||||
fixtures.push(value)
|
||||
return value
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const value of fixtures.splice(0)) value.dispose()
|
||||
})
|
||||
|
||||
describe('TsconfigPathsResolver', () => {
|
||||
it('orders exact, longer-prefix, and longer-suffix path rules', async () => {
|
||||
const files = fixture()
|
||||
files.writeJson('consumer/package.json', {
|
||||
dependencies: {
|
||||
'@scope/feature-name': '*',
|
||||
'@scope/feature-other': '*',
|
||||
'@scope/plain-suffix': '*',
|
||||
},
|
||||
})
|
||||
files.write('targets/exact.ts')
|
||||
files.write('targets/prefix/other.ts')
|
||||
files.write('targets/generic/feature-other.ts')
|
||||
files.write('targets/suffix/plain.ts')
|
||||
files.write('targets/generic/plain-suffix.ts')
|
||||
const resolver = files.createResolver({
|
||||
'@scope/*': ['./targets/generic/*'],
|
||||
'@scope/*-suffix': ['./targets/suffix/*'],
|
||||
'@scope/feature-*': ['./targets/prefix/*'],
|
||||
'@scope/feature-name': ['./targets/exact.ts'],
|
||||
})
|
||||
|
||||
await expect(resolver.resolve('@scope/feature-name', files.parentURL()))
|
||||
.resolves.toBe(pathToFileURL(files.path('targets/exact.ts')).href)
|
||||
await expect(resolver.resolve('@scope/feature-other', files.parentURL()))
|
||||
.resolves.toBe(pathToFileURL(files.path('targets/prefix/other.ts')).href)
|
||||
await expect(resolver.resolve('@scope/plain-suffix', files.parentURL()))
|
||||
.resolves.toBe(pathToFileURL(files.path('targets/suffix/plain.ts')).href)
|
||||
})
|
||||
|
||||
it('resolves only self-references and runtime dependencies from the nearest ancestor manifest', async () => {
|
||||
const files = fixture()
|
||||
files.writeJson('consumer/package.json', {
|
||||
name: 'self-package',
|
||||
dependencies: { dependency: '*' },
|
||||
optionalDependencies: { optional: '*' },
|
||||
peerDependencies: { peer: '*' },
|
||||
})
|
||||
for (const name of ['self-package', 'dependency', 'optional', 'peer', 'undeclared']) {
|
||||
files.write(`targets/${name}.ts`)
|
||||
}
|
||||
const resolver = files.createResolver(Object.fromEntries(
|
||||
['self-package', 'dependency', 'optional', 'peer', 'undeclared']
|
||||
.map(name => [name, [`./targets/${name}`]]),
|
||||
))
|
||||
|
||||
for (const name of ['self-package', 'dependency', 'optional', 'peer']) {
|
||||
await expect(resolver.resolve(name, files.parentURL()))
|
||||
.resolves.toBe(pathToFileURL(files.path(`targets/${name}.ts`)).href)
|
||||
}
|
||||
await expect(resolver.resolve('undeclared', files.parentURL())).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('probes native TypeScript extensions and index files but excludes TSX and missing targets', async () => {
|
||||
const files = fixture()
|
||||
const names = ['plain-ts', 'module-mts', 'common-cts', 'directory', 'tsx-implicit', 'tsx-explicit', 'missing']
|
||||
files.writeJson('consumer/package.json', {
|
||||
dependencies: Object.fromEntries(names.map(name => [name, '*'])),
|
||||
})
|
||||
files.write('targets/plain.ts')
|
||||
files.write('targets/module.mts')
|
||||
files.write('targets/common.cts')
|
||||
files.write('targets/directory/index.ts')
|
||||
files.write('targets/component.tsx')
|
||||
const resolver = files.createResolver({
|
||||
'plain-ts': ['./targets/plain'],
|
||||
'module-mts': ['./targets/module'],
|
||||
'common-cts': ['./targets/common'],
|
||||
'directory': ['./targets/directory'],
|
||||
'tsx-implicit': ['./targets/component'],
|
||||
'tsx-explicit': ['./targets/component.tsx'],
|
||||
'missing': ['./targets/missing'],
|
||||
})
|
||||
|
||||
for (const [name, target] of [
|
||||
['plain-ts', 'targets/plain.ts'],
|
||||
['module-mts', 'targets/module.mts'],
|
||||
['common-cts', 'targets/common.cts'],
|
||||
['directory', 'targets/directory/index.ts'],
|
||||
] as const) {
|
||||
await expect(resolver.resolve(name, files.parentURL()))
|
||||
.resolves.toBe(pathToFileURL(files.path(target)).href)
|
||||
}
|
||||
await expect(resolver.resolve('tsx-implicit', files.parentURL())).resolves.toBeUndefined()
|
||||
await expect(resolver.resolve('tsx-explicit', files.parentURL())).resolves.toBeUndefined()
|
||||
await expect(resolver.resolve('missing', files.parentURL())).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('anchors inherited paths at the config that declared them', async () => {
|
||||
const files = fixture()
|
||||
files.writeJson('consumer/package.json', { dependencies: { custom: '*' } })
|
||||
files.write('targets/custom.ts')
|
||||
files.writeJson('base.json', { compilerOptions: { paths: { custom: ['./targets/custom'] } } })
|
||||
const customTsconfig = files.writeJson('configs/custom.json', { extends: '../base.json' })
|
||||
const resolver = TsconfigPathsResolver.create(customTsconfig)
|
||||
|
||||
await expect(resolver.resolve('custom', files.parentURL()))
|
||||
.resolves.toBe(pathToFileURL(files.path('targets/custom.ts')).href)
|
||||
})
|
||||
|
||||
it('short-circuits matched aliases and delegates unsupported schemes or unmatched requests', async () => {
|
||||
const files = fixture()
|
||||
files.writeJson('consumer/package.json', { dependencies: { matched: '*' } })
|
||||
const target = files.write('targets/matched.ts')
|
||||
const tsconfigPath = files.writeJson('tsconfig.json', {
|
||||
compilerOptions: { paths: { matched: ['./targets/matched'] } },
|
||||
})
|
||||
initialize({ tsconfigPath })
|
||||
const context: ResolveHookContext = {
|
||||
conditions: [],
|
||||
importAttributes: {},
|
||||
parentURL: files.parentURL(),
|
||||
}
|
||||
const nextResolve = vi.fn(async (
|
||||
specifier: string,
|
||||
_context: ResolveHookContext,
|
||||
): Promise<ResolveFnOutput> => ({ url: `next:${specifier}` }))
|
||||
|
||||
await expect(resolveHook('matched', context, nextResolve))
|
||||
.resolves.toEqual({ url: pathToFileURL(target).href, shortCircuit: true })
|
||||
expect(nextResolve).not.toHaveBeenCalled()
|
||||
|
||||
for (const specifier of ['unmatched', 'node:fs', 'data:text/javascript,export default 1', 'https://example.test/mod.ts']) {
|
||||
await expect(resolveHook(specifier, context, nextResolve)).resolves.toEqual({ url: `next:${specifier}` })
|
||||
expect(nextResolve).toHaveBeenLastCalledWith(specifier, context)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,8 +5,9 @@
|
||||
// the code-variant parent row titled by the model-authored description, its
|
||||
// three always-visible nested sub-rows (bash through the sample registration,
|
||||
// read through GenericToolCard, the failing read wearing the error state),
|
||||
// the expanded program body, inert bash / file-link sub-row gestures, and
|
||||
// the trajectory/waterfall tabs' sub-call cells and timing lanes.
|
||||
// the expanded program body, inert bash / file-link sub-row gestures,
|
||||
// details-panel resolution of a sub-callId, and the Trajectory tab's sub-call
|
||||
// cells and timing overview.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
@@ -197,7 +198,7 @@ it('expands the code row into the program body; sub-row clicks do not open detai
|
||||
`)
|
||||
})
|
||||
|
||||
it('trajectory and waterfall surface the run_code sub-calls with real timing', async () => {
|
||||
it('trajectory surfaces run_code sub-calls in the ledger and timing overview', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
@@ -208,51 +209,30 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a
|
||||
}, { timeout: 10_000 })
|
||||
const subCells = [...document.querySelectorAll('[data-kind="subtool"]')]
|
||||
expect({
|
||||
// Three Sub cells nested under the run_code Tool cell, in dispatch order,
|
||||
// each with a real +N.Ns own-duration off the start/settle pair (the
|
||||
// fixture spaces every event 800ms apart — never the em dash).
|
||||
// Three Subtool cells nested under the run_code Tool cell in dispatch
|
||||
// order, each paired with its result preview.
|
||||
subCells: subCells.map(cell => visibleText(cell)),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"subCells": [
|
||||
"#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
|
||||
"#52Subread · {"path":"notes/demo.txt"}+0.8s",
|
||||
"#53Subread · {"path":"notes/missing.txt"}+0.8s",
|
||||
"SUBTOOLbash{"command":"ls notes","description":"List notes"}→demo.txt new-demo.txt",
|
||||
"SUBTOOLread{"path":"notes/demo.txt"}→hello fixture",
|
||||
"SUBTOOLread{"path":"notes/missing.txt"}→error",
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
// Waterfall: each sub-call draws a measured lane scaled into the parent
|
||||
// turn's dispatch window.
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-subspan]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
const lanes = [...document.querySelectorAll('[data-subspan]')]
|
||||
const timelineSubCalls = [...document.querySelectorAll('[data-timeline-span="subtool"]')]
|
||||
expect({
|
||||
lanes: lanes.map(lane => ({
|
||||
label: visibleText(lane.querySelector('[class*="subTag"]') ?? lane),
|
||||
title: lane.querySelector('[data-timing]')?.getAttribute('title'),
|
||||
timing: lane.querySelector('[data-timing]')?.getAttribute('data-timing'),
|
||||
})),
|
||||
count: timelineSubCalls.length,
|
||||
measured: timelineSubCalls.map(span => span.getAttribute('title')?.endsWith(' · 800 ms')),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"lanes": [
|
||||
{
|
||||
"label": "bash",
|
||||
"timing": "measured",
|
||||
"title": "bash · 0.80s",
|
||||
},
|
||||
{
|
||||
"label": "read",
|
||||
"timing": "measured",
|
||||
"title": "read · 0.80s",
|
||||
},
|
||||
{
|
||||
"label": "read",
|
||||
"timing": "measured",
|
||||
"title": "read · 0.80s",
|
||||
},
|
||||
"count": 3,
|
||||
"measured": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -118,14 +118,14 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('a bash sub-row click leaves the details panel collapsed', async () => {
|
||||
it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel open', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details'))
|
||||
const nest = page.locator('[data-subcalls]').first()
|
||||
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
|
||||
await nest.locator('[data-sample="bash-global"]').first().click()
|
||||
// Tool rows no longer open details; the column stays width 0.
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||||
// Tool rows do not drive layout geometry; the Session's default panel stays open.
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Keyless browser regression for the details column's Session ownership.
|
||||
// The shipped composition retains geometry through unselected states and closes it only when a different Session takes ownership.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url))
|
||||
const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
/** Last AppFrame grid track in CSS pixels. */
|
||||
async function detailsTrack(page: Page): Promise<number> {
|
||||
return await appFrame(page).evaluate((element) => {
|
||||
const tracks = getComputedStyle(element).gridTemplateColumns.split(' ')
|
||||
return Number.parseFloat(tracks.at(-1) ?? 'NaN')
|
||||
})
|
||||
}
|
||||
|
||||
/** AppFrame is the only product element with an inline grid track template. */
|
||||
function appFrame(page: Page) {
|
||||
return page.locator('[style*="grid-template-columns"]').first()
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 })
|
||||
await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed')
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await appFrame(page).waitFor({ timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('retains geometry through hero and closes it for a different Session', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle'))
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await settled
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360)
|
||||
expect(await page.getByText('详情', { exact: true }).count()).toBe(1)
|
||||
|
||||
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
|
||||
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
|
||||
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
|
||||
|
||||
const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first()
|
||||
await original.click()
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360)
|
||||
expect(await page.getByText('详情', { exact: true }).count()).toBe(1)
|
||||
|
||||
const ungrouped = page.getByText('Ungrouped', { exact: true })
|
||||
const ungroupedRow = ungrouped.locator('..').locator('..')
|
||||
const ungroupedSection = ungroupedRow.locator('..')
|
||||
await expect.poll(async () => {
|
||||
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
|
||||
await ungrouped.click()
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
return await ungroupedRow.getAttribute('aria-expanded')
|
||||
}, { timeout: 5_000 }).toBe('true')
|
||||
const seeded = ungroupedSection.locator('[role="treeitem"]').nth(1)
|
||||
await seeded.click()
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
@@ -101,23 +101,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
|
||||
it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload'))
|
||||
// Fold a layout preference into the same reload: collapse the sidebar
|
||||
// (persisted under dsh.layout.panels) before reloading.
|
||||
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
// Layout persisted: the sidebar comes back collapsed.
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
|
||||
// Selection persisted (dsh.sessions.current) and history replayed: the
|
||||
// recorded turn re-renders from session.history with zero model calls —
|
||||
// the replay cursor was fully consumed before the reload, so any stray
|
||||
// request would fail the scenario loudly at close().
|
||||
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// Expand back and confirm the tree still lists the materialized session.
|
||||
await page.getByRole('button', { name: 'Open sidebar' }).click()
|
||||
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
|
||||
// Golden of the recovered conversation region: rebuilt from the log, it
|
||||
// must render the same settled transcript the live turn produced.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history
|
||||
// fixture (zero model calls) and pins the settled conversation aria after the
|
||||
// user/assistant footers are focus-revealed — the surface package jsdom tests
|
||||
// cannot substitute for (docs/testing.md snapshot rule).
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url))
|
||||
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
|
||||
// a new recording (workspace-management / sidebar-scrollbar pattern).
|
||||
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'message-actions-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.'
|
||||
|
||||
describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
|
||||
await mkdir(sessionCwd, { recursive: true })
|
||||
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
|
||||
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT])
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions'))
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
|
||||
// hover/focus-within). User has three actions; each finalized assistant
|
||||
// text node has copy + branch.
|
||||
const copyButtons = page.getByRole('button', { name: '复制' })
|
||||
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
await copyButtons.first().focus()
|
||||
await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
|
||||
await page.getByRole('button', {
|
||||
name: '选择模型,当前 deepseek-v4-flash',
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
|
||||
// as an active/focused control during the capture.
|
||||
await page.getByRole('button', { name: '复制' }).first().focus()
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,11 @@
|
||||
// Web e2e scenarios: navigation & panes — the view tabs (Trajectory /
|
||||
// Waterfall) and sidebar search, all over ONE rich two-turn seeded fixture
|
||||
// rendered purely from the log (the seeded-history pattern: zero model calls
|
||||
// in replay, so every surface here is the client fold + host history RPC,
|
||||
// not replay binding). The seed is recorded live under the standard
|
||||
// discipline: turn 1 produces a bash call plus two parallel reads in one
|
||||
// assistant message (tool-call density for the trajectory/waterfall lanes),
|
||||
// turn 2 a markdown-rich reply (a second turn so the waterfall has two lanes).
|
||||
// Web e2e scenarios: navigation & panes — the Trajectory view and timing
|
||||
// overview, its local details inspector, and sidebar search, all over ONE rich
|
||||
// two-turn seeded fixture rendered purely from the log (the seeded-history
|
||||
// pattern: zero model calls in replay, so every surface here is the client
|
||||
// fold + host history RPC, not replay binding). The seed is recorded live
|
||||
// under the standard discipline: turn 1 produces a bash call plus two
|
||||
// parallel reads in one assistant message (tool-call density for the
|
||||
// trajectory ledger/timing lanes), turn 2 a markdown-rich reply.
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -23,7 +23,7 @@ import { saveFailureShot } from './support.ts'
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
|
||||
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
|
||||
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
|
||||
const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
|
||||
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'navigation-panes-web-e2e'
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let slotErrors: string[]
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
@@ -57,6 +58,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
tripwire = watchConsole(page)
|
||||
slotErrors = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
|
||||
slotErrors.push(message.text())
|
||||
}
|
||||
})
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
@@ -122,58 +129,157 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => {
|
||||
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
// Two sticky turn sections; turn 1's step group summarizes its tool mix
|
||||
// (bash + the two parallel reads collapse to 'bash read×2').
|
||||
await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await page.waitForTimeout(100)
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
slotErrors,
|
||||
warnings: tripwire.warnings,
|
||||
}).toEqual({
|
||||
pageErrors: [],
|
||||
slotErrors: [],
|
||||
warnings: [],
|
||||
})
|
||||
// Turn rules partition the ledger without restoring a separate header row.
|
||||
await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
|
||||
await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.locator('tr[data-kind="tool"]').first().click()
|
||||
await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await page.getByRole('tab', { name: 'Result' }).click()
|
||||
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('complementary', { name: 'Event details' })
|
||||
.getByRole('button', { name: 'Close details' }).click()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall'))
|
||||
await page.getByRole('tab', { name: 'Waterfall' }).click()
|
||||
// The stats header rides the waterfall body. The span fold counts THREE
|
||||
// spans for this two-turn log: only assistant/steering nodes carry a turn
|
||||
// number, so the first user message lands in a turn-0 prologue span (a
|
||||
// P-I placeholder shape — pinned as-is; real spans are deferred to
|
||||
// P-III per the view's deviation ledger). Calls: bash + two reads.
|
||||
await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1)
|
||||
// One lane per span, tagged by turn number, prologue included.
|
||||
for (const tag of ['turn 0', 'turn 1', 'turn 2']) {
|
||||
await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
}
|
||||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE)
|
||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|
||||
const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
|
||||
const before = await page.locator('tr[data-kind]').count()
|
||||
const box = await plot.boundingBox()
|
||||
if (box === null) throw new Error('trajectory timeline plot has no layout box')
|
||||
await page.mouse.move(box.x + box.width * 0.55, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 })
|
||||
.toBeGreaterThan(0)
|
||||
await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before)
|
||||
await plot.click({ button: 'right' })
|
||||
await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('bash and file-path rows leave the details column collapsed', async () => {
|
||||
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column open', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
|
||||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||||
const bashRow = page.locator('[data-sample="bash-global"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
|
||||
await bashRow.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
// The card's own controls are outside the summary row and must not open
|
||||
// details either — the terminal card is read in place.
|
||||
await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
// Read summaries are host-open file links; they also must not open details.
|
||||
const fileLink = page.locator('[data-variant="read"] button').first()
|
||||
await fileLink.waitFor({ timeout: 10_000 })
|
||||
await fileLink.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
|
||||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||||
// The card is resident in the keyed bash row (no expand gesture): the
|
||||
// recorded command's own output sits in the message flow, derived from the
|
||||
// logged call/result presentations alone.
|
||||
const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first()
|
||||
await card.waitFor({ timeout: 15_000 })
|
||||
// Real layout, not jsdom's stub (which computes no geometry at all):
|
||||
// squeeze the output pane below its content width and the line must keep
|
||||
// its single row and overflow sideways instead of folding. Soft-wrapping
|
||||
// here is what shredded the column alignment this card exists to hold.
|
||||
const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
|
||||
const pane = node as HTMLElement
|
||||
const row = pane.querySelector<HTMLElement>('[class*="_line_"]')
|
||||
if (row === null) throw new Error('output pane has no line')
|
||||
const before = row.offsetHeight
|
||||
const restore = pane.style.width
|
||||
pane.style.width = '8px'
|
||||
const squeezed = { wrapped: row.offsetHeight > before, scrollsSideways: pane.scrollWidth > pane.clientWidth }
|
||||
pane.style.width = restore
|
||||
return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed }
|
||||
})
|
||||
expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true })
|
||||
// The run-state dot's color is the whole point of it and is the one thing
|
||||
// jsdom cannot report: --dsw-* tokens resolve only against the real theme
|
||||
// stylesheet. This command settled cleanly, so the dot must be the green
|
||||
// success token — a red one here would read as a failed command.
|
||||
const dot = await card.locator('[class*="_runState_"][data-state]').first().evaluate((node) => {
|
||||
// The token lives on body, so the probe must sit in the same cascade.
|
||||
const probe = document.createElement('span')
|
||||
probe.style.color = 'var(--dsw-alias-state-success-primary)'
|
||||
document.body.appendChild(probe)
|
||||
const success = getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
return {
|
||||
state: node.getAttribute('data-state'),
|
||||
color: getComputedStyle(node as HTMLElement).color,
|
||||
success,
|
||||
// One label per card (the state is the call's), so it hangs off the
|
||||
// prompt column rather than the row the dot sits in.
|
||||
label: node.closest('[class*="_prompt_"]')?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
|
||||
// The dot precedes the prompt label in document order, which is what
|
||||
// puts it to the left of the `$`.
|
||||
beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!)
|
||||
=== Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
// The dot lives in the card's OWN left padding, so it sits inside the
|
||||
// card box yet left of the prompt text. Owning the reservation as padding
|
||||
// rather than margin is what keeps a consumer's own margin from
|
||||
// cancelling it and letting a container clip the dot — geometry jsdom
|
||||
// cannot compute.
|
||||
insideCard: (node as HTMLElement).getBoundingClientRect().left
|
||||
>= (node.closest('[data-terminal]')?.getBoundingClientRect().left ?? Infinity),
|
||||
leftOfPrompt: (node as HTMLElement).getBoundingClientRect().right
|
||||
<= (node.closest('[class*="_promptLine_"]')
|
||||
?.querySelector('[class*="_cwd_"]')
|
||||
?.getBoundingClientRect().left ?? -Infinity),
|
||||
}
|
||||
})
|
||||
expect(dot.state).toBe('done')
|
||||
expect(dot.label).toBe('已完成')
|
||||
expect(dot.beforePrompt).toBe(true)
|
||||
expect(dot.insideCard).toBe(true)
|
||||
expect(dot.leftOfPrompt).toBe(true)
|
||||
// Resolved through the theme token, not a literal hex in the component.
|
||||
expect(dot.success).toMatch(/^rgb/)
|
||||
expect(dot.color).toBe(dot.success)
|
||||
// Golden of the card at rest — captured before the copy click, whose
|
||||
// confirmation label self-reverts on a timer and would not hold still.
|
||||
const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
|
||||
// Copy writes the raw output through the browser's own clipboard, which in
|
||||
// a real page is the async Clipboard API rather than the jsdom fallback.
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await card.locator('[class*="_copyButton_"]').first().click()
|
||||
await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 })
|
||||
.toBe('复制成功')
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(slotErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md',
|
||||
'seed.jsonl', 'trajectory.expected.md', 'terminal-card.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -104,7 +104,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
const inner = child.getBoundingClientRect()
|
||||
return Math.max(box.top - inner.top, inner.bottom - box.bottom)
|
||||
})))
|
||||
const list = rows[0]?.parentElement ?? null
|
||||
const list = card.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
return {
|
||||
rows: rows.length,
|
||||
spill: Math.max(...spill),
|
||||
|
||||
@@ -389,6 +389,11 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
.split(base).join('{{workspace}}')
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
|
||||
.replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
|
||||
// Message IconActions clocks widen by calendar day/year; collapse every
|
||||
// shape so goldens stay stable across midnight and year boundaries.
|
||||
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
.replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
.replace(/(?<!\d)\d{2}:\d{2}(?!\d)/g, '{{clock}}')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,10 +139,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
// capture; still zero model calls.
|
||||
const fileLink = page.locator('[data-variant="read"] button').first()
|
||||
await fileLink.waitFor({ timeout: 10_000 })
|
||||
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
|
||||
await fileLink.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
// Path label survives from the recorded args (a.txt).
|
||||
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
// Session row actions in the assembled fixture app: Rename opens the
|
||||
// browser-owned dialog and settles the title from the unary response.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
document.title = 'DeepSeek Harness'
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function bootApp(): Promise<void> {
|
||||
const root = document.querySelector<HTMLElement>('#root')
|
||||
if (root === null) throw new Error('snapshot root missing')
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
/** The session row element carrying the given visible label. */
|
||||
function rowOf(label: string): HTMLElement {
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const row = within(tree).getByText(label).closest<HTMLElement>('[role="treeitem"]')
|
||||
if (row === null) throw new Error(`session row "${label}" missing`)
|
||||
return row
|
||||
}
|
||||
|
||||
/** Open the row's ... menu and click one action. The anchor button is
|
||||
* CSS-hover-revealed (real stylesheets are injected in this assembled run,
|
||||
* so role queries filter it as hidden); target it directly. */
|
||||
function pickRowAction(label: string, action: string): void {
|
||||
const anchor = rowOf(label).querySelector<HTMLElement>(`button[aria-label="Session actions for ${label}"]`)
|
||||
if (anchor === null) throw new Error(`row menu anchor for "${label}" missing`)
|
||||
fireEvent.click(anchor)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: action, hidden: true }))
|
||||
}
|
||||
|
||||
it('renames a session through the row-menu dialog; the row settles from the unary response', async () => {
|
||||
await bootApp()
|
||||
const sourceLabel = 'Fixture 历史会话'
|
||||
await screen.findByText(sourceLabel)
|
||||
|
||||
pickRowAction(sourceLabel, 'Rename')
|
||||
const input = await screen.findByLabelText('Session name')
|
||||
expect((input as HTMLInputElement).value).toBe(sourceLabel)
|
||||
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
|
||||
// Host-side normalization collapses whitespace; the dialog closes on
|
||||
// acceptance and the row re-labels without any push-frame wait.
|
||||
const renamed = '分叉 实验记录'
|
||||
await waitFor(() => { expect(screen.queryByLabelText('Session name')).toBeNull() })
|
||||
await screen.findByText(renamed)
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
expect(within(tree).queryByText(sourceLabel)).toBeNull()
|
||||
|
||||
const rows = [...tree.querySelectorAll('[role="treeitem"]')].map(row => ({
|
||||
label: row.textContent?.replace(/\s+/g, ' ').trim() ?? '',
|
||||
}))
|
||||
await expect(`${JSON.stringify(rows, null, 2)}\n`)
|
||||
.toMatchFileSnapshot('./snapshots/session-actions/rename-rows.json')
|
||||
})
|
||||
@@ -143,7 +143,7 @@ it('projects titles and routes the next turn through the selected model in the b
|
||||
// allowed for the next turn; stop the fixture's resident run before sending
|
||||
// the route-report prompt.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop generating' }))
|
||||
const composer = await screen.findByPlaceholderText('Message the agent')
|
||||
const composer = await screen.findByPlaceholderText('给智能体发消息')
|
||||
fireEvent.change(composer, { target: { value: 'report model' } })
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await screen.findByText('当前模型:openai/gpt-5 · 推理等级:max', {}, { timeout: 10_000 })
|
||||
|
||||
@@ -57,12 +57,16 @@ class ResizeObserverStub {
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
// jsdom has no scrollIntoView; the slash menu follows its highlighted option.
|
||||
const scrollIntoView = vi.fn()
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
scrollIntoView.mockClear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
@@ -80,6 +84,7 @@ afterEach(() => {
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
|
||||
@@ -357,16 +357,20 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
requireDist()
|
||||
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
|
||||
const port = await probeFreePort()
|
||||
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. cwd is a
|
||||
// temp dir (persistenceRoot is cwd-relative), so tsx needs the repo's loader
|
||||
// and tsconfig paths pointed at explicitly.
|
||||
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate
|
||||
// the global Harness home inside the temp world; tsx also needs the repo's
|
||||
// loader and tsconfig paths pointed at explicitly.
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
|
||||
{
|
||||
cwd: sessionsDir,
|
||||
env: { ...process.env, TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json') },
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: join(sessionsDir, '.dsh'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
@@ -440,17 +444,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '04-round-complete')
|
||||
}, 150_000)
|
||||
|
||||
it('4 view tabs: Chat / Trajectory / Waterfall all switch', async () => {
|
||||
it('view tabs: Chat and Trajectory switch', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tabs'))
|
||||
await page.locator('button', { hasText: /Trajectory/i }).first().click()
|
||||
await screen(page, '05-trajectory-tab')
|
||||
await page.locator('button', { hasText: /Waterfall/i }).first().click()
|
||||
await screen(page, '06-waterfall-tab')
|
||||
await page.getByLabel('Trajectory timeline').waitFor()
|
||||
await expect.poll(() => page.getByRole('tab', { name: 'Waterfall' }).count()).toBe(0)
|
||||
await page.locator('button', { hasText: /^Chat$/i }).first().click()
|
||||
await screen(page, '07-back-to-chat')
|
||||
})
|
||||
|
||||
it('5 bash differential rendering: tool row click leaves the details column collapsed', async () => {
|
||||
it('5 bash differential rendering: tool row click leaves the default details column open', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
|
||||
@@ -462,14 +466,14 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
const toolRow = page.locator('[data-sample="bash-global"]')
|
||||
await toolRow.waitFor({ timeout: 120_000 })
|
||||
await screen(page, '08-bash-round')
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
expect(await detailsTrack(page)).toBe(360)
|
||||
await toolRow.click()
|
||||
// Tool rows no longer drive layout.openDetails; the column stays closed.
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await screen(page, '09-details-closed')
|
||||
// Tool rows no longer drive layout.openDetails; the default column stays open.
|
||||
expect(await detailsTrack(page)).toBe(360)
|
||||
await screen(page, '09-details-open')
|
||||
}, 150_000)
|
||||
|
||||
it('6 sidebar drag widens the column and persists across reload', async () => {
|
||||
it('6 sidebar drag widens the column and resets across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-drag'))
|
||||
const before = await firstTrack(page)
|
||||
const handle = page.locator('[class*="handle"]').first()
|
||||
@@ -484,7 +488,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '10-sidebar-dragged')
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await firstTrack(page)).toBe(after)
|
||||
expect(await firstTrack(page)).toBe(before)
|
||||
})
|
||||
|
||||
it('7 dark mode: the body attribute cascades the token sheets', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785013630399,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785013630411,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"1c4a6a0f-a7bd-4642-919d-6ed7395df98b"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785013630418,"data":{"title":"Using ONE run_code program: run","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785013630479,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785013630480,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -12,13 +12,13 @@
|
||||
{"type":"assistant/chunk","seq":202,"time":1785013633103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":203,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}}}
|
||||
{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0d217af9-3bb3-425b-b53e-7ab0366f0c66"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
|
||||
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
|
||||
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
|
||||
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_6VNoF1gDSerTBKoCfYSH3765"},"content":[{"type":"tool-result","toolCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false}],"role":"user","id":"1580d4a8-5760-415f-984f-f93927271d3f"}},"sourceEventSeqs":[206],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":214,"time":1785013633985,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -30,6 +30,6 @@
|
||||
{"type":"assistant/chunk","seq":233,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":234,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}}}}
|
||||
{"type":"assistant/chunk","seq":235,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3037ec34-6c2a-4e01-91eb-a0ef89ce0d15"}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":237,"time":1785013634225,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":238,"time":1785013634225,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,18 +4,23 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."
|
||||
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- 'button "Think The user wants me to write a single `run_code` program that:"':
|
||||
- img
|
||||
- img
|
||||
- text: "Think The user wants me to write a single `run_code` program that:"
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
@@ -28,7 +33,11 @@
|
||||
- img
|
||||
- text: Think The program ran successfully. Let me now reply DONE as instructed.
|
||||
- paragraph: DONE
|
||||
- text: cache hit 52% · 17,490 tokens · 1 turns · 2 steps
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785157562825,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785157562881,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"27e1fc72-20d0-4875-bf5b-d34750441f30"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785157562883,"data":{"title":"Use only Cordis tools. First","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785157562937,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785157562938,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -12,9 +12,9 @@
|
||||
{"type":"assistant/chunk","seq":100,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":101,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}}}}
|
||||
{"type":"assistant/chunk","seq":102,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a3b56157-c6fd-43f8-bc8a-0e2a0c99a8b5"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":104,"time":1785157565496,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}
|
||||
{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false},"sourceEventSeqs":[104],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_KZk918WtlKan9pHMULIT8794"},"content":[{"type":"tool-result","toolCallId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false}],"role":"user","id":"95ae124e-f065-4aab-a9c5-bd33c7eafff1"}},"sourceEventSeqs":[104],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":106,"time":1785157565503,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":107,"time":1785157565503,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":108,"time":1785157566524,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -25,9 +25,9 @@
|
||||
{"type":"assistant/chunk","seq":157,"time":1785157567041,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":158,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}}}}
|
||||
{"type":"assistant/chunk","seq":159,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b90af675-f604-4fb4-9c27-89c91b27f4ce"}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":161,"time":1785157567043,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}
|
||||
{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[161],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361"},"content":[{"type":"tool-result","toolCallId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"3148fc0a-f511-4b72-abd5-d184e974cd49"}},"sourceEventSeqs":[161],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":163,"time":1785157567050,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":164,"time":1785157567050,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":165,"time":1785157567835,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -38,9 +38,9 @@
|
||||
{"type":"assistant/chunk","seq":204,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":205,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}}}}
|
||||
{"type":"assistant/chunk","seq":206,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdc5b840-4302-486a-8065-fa59bf13f2d9"}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":208,"time":1785157568280,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
|
||||
{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[208],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_e38S6zeYdZGvbhecUCil6659"},"content":[{"type":"tool-result","toolCallId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"758a7b27-56e3-4997-8c4d-effc699cc5e6"}},"sourceEventSeqs":[208],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":210,"time":1785157568282,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":211,"time":1785157568282,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":212,"time":1785157569185,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -51,6 +51,6 @@
|
||||
{"type":"assistant/chunk","seq":244,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CORDIS_UI_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":245,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}}}}
|
||||
{"type":"assistant/chunk","seq":246,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"71540121-7bf6-410e-975c-f5ea99d8175a"}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":248,"time":1785157569554,"data":{"turn":1,"step":4}}
|
||||
{"type":"turn/end","seq":249,"time":1785157569554,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,18 +4,23 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."
|
||||
- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to:":
|
||||
- img
|
||||
- img
|
||||
- text: "Think The user wants me to:"
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
@@ -24,6 +29,11 @@
|
||||
- img
|
||||
- img
|
||||
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- button [expanded]:
|
||||
- img
|
||||
- text: Mount temporary Plugin typescript
|
||||
@@ -33,6 +43,11 @@
|
||||
- img
|
||||
- img
|
||||
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
@@ -42,7 +57,11 @@
|
||||
- img
|
||||
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
|
||||
- paragraph: CORDIS_UI_DONE
|
||||
- text: cache hit 77% · 66,813 tokens · 1 turns · 4 steps
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"2c594e5a-bbcc-4c64-b5ee-8e84eb5dd949"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -12,9 +12,9 @@
|
||||
{"type":"assistant/chunk","seq":52,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":53,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}}
|
||||
{"type":"assistant/chunk","seq":54,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"e5d8d5fb-f555-4fda-948f-9ab178145929"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":56,"time":1784973851499,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}
|
||||
{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_BYXlxjFaalMg95YVqEeF2495"},"content":[{"type":"tool-result","toolCallId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false}],"role":"user","id":"215febf9-ff3c-42b8-93b7-27ce1505c1c2"}},"sourceEventSeqs":[56],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":58,"time":1784973851517,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":59,"time":1784973851518,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":60,"time":1784973852194,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -26,6 +26,6 @@
|
||||
{"type":"assistant/chunk","seq":88,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":89,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}}}}
|
||||
{"type":"assistant/chunk","seq":90,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"61e83a98-18a9-4f1f-9151-c8783cc39901"}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":92,"time":1784973852461,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":93,"time":1784973852462,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,18 +4,23 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."
|
||||
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to run a simple bash command and reply with "DONE".
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- img
|
||||
- text: Bash Echo the test string
|
||||
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
|
||||
@@ -23,7 +28,11 @@
|
||||
- img
|
||||
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
|
||||
- paragraph: DONE
|
||||
- text: cache hit 99% · 15,818 tokens · 1 turns · 2 steps
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -4,20 +4,24 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Reply with the single word LIGHTHOUSE and stop.
|
||||
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to reply with a single word. Let me comply.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to reply with a single word. Let me comply.
|
||||
- paragraph: LIGHTHOUSE
|
||||
- text: cache hit 99% · 7,810 tokens · 1 turns · 1 steps
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"ee28d2ee-08b2-4ed7-a1e9-84865f55e2af"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
@@ -12,6 +12,6 @@
|
||||
{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2101eadc-3475-4c77-ae0a-0107b12c34bc"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,16 +4,21 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop.
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- paragraph: partial
|
||||
- text: 已停止 0 tokens · 1 turns · 1 steps
|
||||
- text: 已停止
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}} 0 tokens · 1 turns · 1 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user