Merge origin/master: settings seam lands beside web plan mode
Conflicts were the parallel-edit set: packages/README group table (kept master's session-title wording, re-added the settings row condensed), doc-budgets AGENTS.md ceiling (took master's 1750), and regenerated graph/catalog docs plus re-recorded translation pairs. packages/README.md ceiling 845 -> 850: the table legitimately gained one row for the new settings group and the row is already minimal.
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
|
||||
2026-06-14-session-persistence.md: 75e13b860f621ed407849b3b4c62ff7287ab4812
|
||||
2026-06-14-session-persistence.zh.md: a6bd400a053779c742940236737447d1687622de
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
|
||||
2026-06-14-session-persistence.md: 137b2b01126214629952812f3dd3b71985a3acda
|
||||
2026-06-14-session-persistence.zh.md: 2846ee92349c297fb3a173ba9dd3e2ff3cd9ee1a
|
||||
@@ -29,7 +29,7 @@ Key choices recorded here because they are durable, contested, and surprising:
|
||||
|
||||
Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
|
||||
|
||||
Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
|
||||
Format versioning: the header carries a `version`; `load` rejects any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Status: implemented
|
||||
|
||||
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
|
||||
|
||||
格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。
|
||||
格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
+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-22-unified-send-and-coalesced-user-messages.md
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.md: 6936fbfa04c0fdaf1a8786c0465c193e9c285243
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 3af14359fa01e92f63ae3b3e51dced9a97f6419f
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.md: ed171735cf483938c70291963a6e68dc02d7bde2
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 8b2a3ebabb493954e653255e876255b9c0810c19
|
||||
+7
-6
@@ -12,21 +12,21 @@ Separately, `context/message` and `user/message` had converged: the surface proj
|
||||
|
||||
## Decision
|
||||
|
||||
**One primitive, three preset aliases.** The `Agent` interface's `send(input, { target, wakeup })` covers the (`target` × `wakeup`) matrix. Its `UserMessageData` input owns the inseparable model-facing `content` and producer `source`; the complete `SendOptions` owns only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one input and fix the policy. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller.
|
||||
**One primitive, three preset aliases.** The `Agent` interface's `send(message, { target, wakeup })` covers the (`target` × `wakeup`) matrix. Its complete `UserMessage` owns identity, role, model-facing `content`, and producer `source`; the complete `SendOptions` owns only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one message and fix the policy. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller.
|
||||
|
||||
**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position, deferred while prompt admission or a turn owns the next safe boundary, and appended directly outside that window. It bypasses the FIFOs entirely, while its required `UserMessageData.source` preserves the caller's explicit provenance.
|
||||
**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position, deferred while prompt admission or a turn owns the next safe boundary, and appended directly outside that window. It bypasses the FIFOs entirely, while its required `UserMessage.source` preserves the caller's explicit provenance.
|
||||
|
||||
**context/message is gone.** Injected context is now a `user/message`; context producers supply the appropriate non-user `source` explicitly, and typed source variants carry any domain-specific durable provenance. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type.
|
||||
|
||||
**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` whose source carries the complete change; a positive round is an admitted continuation prompt. `decodeGoalEvent` takes a `user/message` and fails loud when goal-state content and its typed source disagree.
|
||||
|
||||
**`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`.
|
||||
**`send` does not return identity.** Callers already own the complete message and its opaque `MessageId`; creation and freezing are owned by the [identified immutable message decision](2026-07-28-identified-immutable-message-values.md), not by routing.
|
||||
|
||||
**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) type their `AgentMessage` payload with only the accepted message's returned `id`, content, and source. Enqueue separately carries the resolved `queued | steering` placement captured by the producer at acceptance time, so observers and reconnect mirrors never reconstruct routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including steering submitted by an `agent/turn-stopping` listener, so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative.
|
||||
**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) carry the accepted `UserMessage`. Enqueue and dequeue also carry the resolved `queued | steering` placement captured at acceptance, so observers and reconnect mirrors retire repeated message identities from the correct FIFO without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including steering submitted by an `agent/turn-stopping` listener, so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative.
|
||||
|
||||
**Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate.
|
||||
|
||||
**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use `UserMessageData { content, source }` directly; public `AgentMessage` extends it with the correlation `id`, and the loop-private `PendingMessage` extends that with `wakeup`. The loop clones and freezes `UserMessageData` before publication, queueing, or immediate append, so later caller or observer mutation cannot change the accepted value. A queued message that becomes steering enters the outbox as the same `PendingMessage` object, while injected and tool-produced context enters as plain `UserMessageData`. The outbox therefore stores their union directly instead of wrapping steering beside a duplicate copy of its content and source. Provider-native assistant messages remain adapter-owned output types and do not participate in this input hierarchy.
|
||||
**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use the identified, frozen `UserMessage` directly. The loop stores that value beside private routing state rather than copying its identity, content, or source into another public shape. A queued message that becomes steering keeps the same message value in the outbox, while injected and tool-produced context each carry their own identified message. The [identified immutable message decision](2026-07-28-identified-immutable-message-values.md) supersedes this note's former `UserMessageData`/`AgentMessage` hierarchy and extends the representation to assistant and tool-result messages.
|
||||
|
||||
**Idle wakeup follows acceptance.** Before publishing enqueue, a waking queued send installs quiescence ownership and schedules driver admission for a microtask that runs after the id returns. Every send in one synchronous caller stack therefore resolves placement against the same pre-admission state, while reentrant cancellation or teardown cannot retire before the scheduled admission settles. Two idle `steer()` calls remain two FIFO turns instead of the first opening an admission window that captures the second.
|
||||
|
||||
@@ -35,7 +35,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj
|
||||
## Alternatives considered
|
||||
|
||||
- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Plugin-produced injected context supplies its plugin source explicitly.
|
||||
- **A typed discriminant field on `UserMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact.
|
||||
- **A typed discriminant field on `UserMessage`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact.
|
||||
- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the resolved placement, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe.
|
||||
- **Derive inbox placement from agent status or the session log.** Rejected because `running` includes admission and settlement, while reconnect baselines need the original acceptance result even when the earlier turn boundary is absent. The producer already owns the exact routing decision.
|
||||
|
||||
@@ -50,3 +50,4 @@ The delivery surface is now one primitive plus three self-documenting presets, a
|
||||
- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on.
|
||||
- [remove-agent-steering-mirror](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event.
|
||||
- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends.
|
||||
- [identified immutable message values](2026-07-28-identified-immutable-message-values.md) — the message identity and representation contract that now underlies this routing decision.
|
||||
+7
-6
@@ -12,21 +12,21 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
|
||||
|
||||
## 决策
|
||||
|
||||
**一个原语,三个预设别名。** `Agent` 接口的 `send(input, { target, wakeup })` 覆盖 (`target` × `wakeup`) 矩阵。其 `UserMessageData` 输入持有不可分割的模型可见 `content` 与生产方 `source`;完整的 `SendOptions` 只持有路由策略。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)都接收这一项输入并固定策略。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`next-turn`/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。
|
||||
**一个原语,三个预设别名。** `Agent` 接口的 `send(message, { target, wakeup })` 覆盖 (`target` × `wakeup`) 矩阵。完整的 `UserMessage` 持有标识、角色、模型可见 `content` 与生产方 `source`;完整的 `SendOptions` 只持有路由策略。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)都接收这一条消息并固定策略。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`next-turn`/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。
|
||||
|
||||
**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;提示词准入或一个轮次占有下一个安全边界时,它会延迟处理,而在该窗口之外则直接追加。它完全绕过 FIFO 队列,而必填的 `UserMessageData.source` 会保留调用方显式提供的来源信息。
|
||||
**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;提示词准入或一个轮次占有下一个安全边界时,它会延迟处理,而在该窗口之外则直接追加。它完全绕过 FIFO 队列,而必填的 `UserMessage.source` 会保留调用方显式提供的来源信息。
|
||||
|
||||
**context/message 已移除。** 注入的上下文现在是一条 `user/message`;上下文生产方显式提供合适的非 `user` 类别 `source`,类型化 source 变体携带所有特定于领域的持久来源信息。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。
|
||||
|
||||
**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,其 source 携带完整变更;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 接收一条 `user/message`,并在 goal 状态内容与其类型化 source 不一致时立即报错。
|
||||
|
||||
**`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。
|
||||
**`send` 不返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责。
|
||||
|
||||
**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都将各自的 `AgentMessage` 载荷类型限定为仅包含被接受消息所返回的 `id`、内容和来源。enqueue 还会单独携带生产方在接受消息时捕获的已解析 `queued | steering` 放置方式,因此观察方和重连镜像永远不必根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括 `agent/turn-stopping` 监听器提交的 steering,因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。
|
||||
**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都会携带已接受的 `UserMessage`。enqueue 和 dequeue 还会携带生产方在接受消息时捕获的已解析 `queued | steering` 放置方式,因此观察方和重连镜像可以从正确的 FIFO 中结算重复出现的消息标识,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括 `agent/turn-stopping` 监听器提交的 steering,因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。
|
||||
|
||||
**准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。
|
||||
|
||||
**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用 `UserMessageData { content, source }`;公开的 `AgentMessage` 在此基础上增加用于关联的 `id`,循环私有的 `PendingMessage` 再增加 `wakeup`。循环会在发布、入队或立即追加前克隆并冻结 `UserMessageData`,因此调用方或观察方后续的修改无法改变已接受的值。一条成为 steering 的排队消息会以同一个 `PendingMessage` 对象进入 outbox,而注入和工具产生的上下文则以普通 `UserMessageData` 进入。因此,outbox 直接存储这两种类型的联合,而不再把 steering 与一份重复的内容和来源副本包装在一起。提供方原生的助手消息仍是适配器拥有的输出类型,不参与这套输入层级。
|
||||
**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用带标识且冻结的 `UserMessage`。循环把该值与私有路由状态存放在一起,不会将其标识、内容或来源复制到另一种公开形状中。一条成为 steering 的排队消息会在 outbox 中保留同一个消息值,而注入和工具产生的上下文则各自携带带标识的消息。[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)取代了本记录此前的 `UserMessageData`/`AgentMessage` 层级,并将这一表示扩展到 assistant 消息和工具结果消息。
|
||||
|
||||
**空闲唤醒在接受之后发生。** 在发布 enqueue 前,一次会唤醒驱动器的排队发送会先取得完全停稳所有权,并把驱动器准入调度到一个会在该次发送返回 id 后运行的微任务中。因此,同一同步调用栈中的每次发送都会基于同一份准入前状态解析放置方式,而可重入的取消或拆除在已调度的准入结算前无法完成退役。空闲时的两次 `steer()` 调用会保留为两个 FIFO 轮次,而不会因第一次调用打开准入窗口而把第二次吸纳进去。
|
||||
|
||||
@@ -35,7 +35,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。由插件产生的注入上下文会显式提供其 plugin 来源。
|
||||
- **在 `UserMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。
|
||||
- **在 `UserMessage` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。
|
||||
- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是带有已解析的放置方式,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。
|
||||
- **根据 agent 状态或会话日志推导 inbox 放置方式。** 不予采纳,因为 `running` 同时涵盖准入与结算,而重连基线即使缺少此前的轮次边界,也需要最初的接受结果。生产方已经拥有精确的路由决策。
|
||||
|
||||
@@ -50,3 +50,4 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
|
||||
- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。
|
||||
- [remove-agent-steering-mirror](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。
|
||||
- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。
|
||||
- [带标识的不可变消息值](2026-07-28-identified-immutable-message-values.md)——本路由决策现在所依托的消息标识与表示契约。
|
||||
+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-24-separate-context-injection-from-turn-execution.md
|
||||
2026-07-24-separate-context-injection-from-turn-execution.md: d44ef5afc8c376790192998bcf3069ceb651ae82
|
||||
2026-07-24-separate-context-injection-from-turn-execution.zh.md: ba561b587effb278a67844ce4d876da9cd940e94
|
||||
2026-07-24-separate-context-injection-from-turn-execution.md: bf3ae2ecbd2205a4c49e8004ffc694f89a2460a3
|
||||
2026-07-24-separate-context-injection-from-turn-execution.zh.md: a805eb651c5c77f3d37c92dacd116bb41f154ed7
|
||||
+2
-2
@@ -18,7 +18,7 @@ Idle `inject()` exposed a second mismatch. Injection did not request model execu
|
||||
|
||||
`inject()` is the only caller-facing operation for supplementary model-facing input, and a turn means one execution of the model loop.
|
||||
|
||||
`SendOptions` contains only `target` and `wakeup`. A caller that owns context delivers `UserMessageData` through `inject()` and submits the direct message independently with `send()` or `steer()`.
|
||||
`SendOptions` contains only `target` and `wakeup`. A caller that owns context delivers an identified, frozen `UserMessage` through `inject()` and submits the direct message independently with `send()` or `steer()`.
|
||||
|
||||
Prompt and tool extension points still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt and its returned additional contexts enter the new turn as separate messages; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the outbox after the corresponding tool results.
|
||||
|
||||
@@ -59,7 +59,7 @@ This decision preserves the caller-owned framing decision from [unwrapped inject
|
||||
## Verification
|
||||
|
||||
- `SendOptions` and steering inbox records contain no attached contexts; `agent/inbox/enqueue` reports only the message plus its resolved queued-or-steering placement.
|
||||
- `UserMessageData` is the shared shape across prompt interception, tool execution, hook bridges, guards, and context producers.
|
||||
- `UserMessage` is the shared identified, frozen shape across prompt interception, tool execution, hook bridges, guards, and context producers.
|
||||
- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay.
|
||||
- Idle `inject()` appends one sourced `user/message` without a turn or model call.
|
||||
- Admission-time and active-turn injection drain at safe boundaries after complete tool-result batches and before the request that consumes them.
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入:
|
||||
|
||||
`inject()` 是调用方交付补充模型输入的唯一操作,而轮次表示一次模型循环执行。
|
||||
|
||||
`SendOptions` 只包含 `target` 和 `wakeup`。拥有上下文的调用方通过 `inject()` 交付 `UserMessageData`,再独立使用 `send()` 或 `steer()` 提交直接消息。
|
||||
`SendOptions` 只包含 `target` 和 `wakeup`。拥有上下文的调用方通过 `inject()` 交付带标识且冻结的 `UserMessage`,再独立使用 `send()` 或 `steer()` 提交直接消息。
|
||||
|
||||
提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。获准的提示词及其返回的额外上下文会作为独立消息进入新轮次;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入 outbox。
|
||||
|
||||
@@ -59,7 +59,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入:
|
||||
## 验证
|
||||
|
||||
- `SendOptions` 与 steering 收件箱记录不包含附加上下文;`agent/inbox/enqueue` 只报告消息及其已解析的 queued 或 steering 放置方式。
|
||||
- `UserMessageData` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的形状。
|
||||
- `UserMessage` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的带标识且冻结的形状。
|
||||
- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。
|
||||
- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加一条带来源的 `user/message`。
|
||||
- 准入期间和活跃轮次中的注入会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。
|
||||
|
||||
+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/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
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Native TypeScript source launch for dsh
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md)
|
||||
|
||||
## 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.
|
||||
|
||||
Node's transform also does not perform type analysis. A type imported through an ordinary value import remains a runtime ESM request, and TypeScript's `export =` becomes a CommonJS assignment rather than an ESM default export. The source graph therefore has to use explicit type-only imports and native ESM exports; a resolve hook cannot repair incompatible source syntax.
|
||||
|
||||
Cordis configuration introduces a separate resolution boundary. Bare plugins in `cordis.yml` do not pass through TypeScript import analysis, so their resolver manifest may omit the required dependencies. The Cordis Loader logs plugin import errors and leaves an entry without a fiber, but does not fail startup itself; a typo in the configuration can therefore produce an incomplete application with exit code 0.
|
||||
|
||||
## Decision
|
||||
|
||||
The `dsh` TUI, Web, and headless source launches use `node --experimental-transform-types`; Node performs TypeScript transformation without loading `tsx` or esbuild. `bin/dsh`, the root-level `dsh`/TUI/Web demos, and Code Mode TUI enter the same `apps/cli/src/bin.ts` launch chain. Test and E2E launchers retain their existing strategies, and the built `lib/bin.js` continues to run under ordinary Node.
|
||||
|
||||
`scripts/tspath-loader.ts` registers only a module resolve hook. It uses `TSX_TSCONFIG_PATH` when set (resolving relative values from the invoking cwd) and otherwise reads the root `tsconfig.json`; `TsconfigPathsResolver` follows that config's `extends` chain through the repository's existing TypeScript development tool, selects exact or wildcard `paths` entries according to tsconfig rules, and maps matching workspace bare specifiers to `.ts`/`.mts`/`.cts` source files or directory index files. Node remains solely responsible for code transformation. The source-only loader is not part of the built CLI and `apps/cli` does not declare `typescript` as a runtime dependency.
|
||||
|
||||
Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/cordis.yml` lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution.
|
||||
|
||||
`verify-cordis-config` performs a one-way completeness check on both resolver manifests: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule.
|
||||
|
||||
After the Loader settles, the shared `dsh-app-boot` checks every enabled entry that has no fiber and rejects startup with `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved`, listing all failed plugins. This diagnostic lives at the app layer and does not change the vendored Loader's startup behavior.
|
||||
|
||||
Node-compatible TypeScript is part of this source-launch contract. Vendored Cordis, Loader, Include, HMR, and Schemastery mark erased imports with `import type`. Schemastery uses a native ESM default export and declares `type: module`; its `.mjs` and `.cjs` build outputs retain the existing ESM-default and callable-`require()` behavior. These divergences are recorded in `vendor/README.md`; no runtime behavior is added to the vendored frameworks.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Continue using `tsx`.** Rejected because `tsx`/esbuild would continue to own TypeScript transformation, so this launch chain could not prove that Node's native transformation works.
|
||||
|
||||
**Load the built `lib/` through package exports from the source entry point.** Rejected because this would mix the source plane with the artifact plane; a zero-build development launch could read stale artifacts or fail outright.
|
||||
|
||||
**Apply the root tsconfig `paths` unconditionally.** Rejected because this would allow undeclared cross-package imports and Cordis plugins to keep resolving, hiding mismatches between the manifest and the actual runtime graph.
|
||||
|
||||
**Transform imports inside the custom loader.** Rejected because type-aware source rewriting would reintroduce a compiler-style transform and make the loader, rather than Node, responsible for TypeScript execution. Making the checked-in source Node-compatible keeps the launch boundary explicit.
|
||||
|
||||
## Consequences
|
||||
|
||||
- TUI/headless retain a zero-build source loop, while Web still builds its frontend artifacts before starting the CLI source entry. TypeScript syntax passes only through Node's native transform; the URL-only loader uses the checkout's root development dependencies and adds no CLI runtime dependency.
|
||||
- Workspace package imports and Cordis configuration dependencies must both be declared explicitly in the resolver manifest; the static gate prevents configuration from landing before its dependencies, while extra dependencies are not errors.
|
||||
- Plugin import failures no longer leave an incomplete application with exit code 0; the final error identifies both the Cordis startup failure and the specific plugin names, while the Loader's original error remains earlier in the logs.
|
||||
- Vendored source in the CLI graph must remain compatible with Node's transform-types module semantics; the local-modification log makes the upstream sync obligation explicit.
|
||||
- CI's `lib` mode, test/E2E launchers, and other example launchers retain their existing strategies; this native source loader covers only the `dsh` CLI application chain.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Agent Note: dsh 原生 TypeScript 源码启动
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh` 源码入口原本使用 `tsx` 运行 `apps/cli/src/bin.ts`,TypeScript 转换和根 tsconfig 的 `paths` 解析都由同一个第三方 loader 隐式处理。改由 Node 原生处理 TypeScript 后,Node 不会应用 tsconfig 路径映射;如果改为通过包导出解析,源码启动会混入可能陈旧或不存在的 `lib/` 产物。
|
||||
|
||||
Node 的转换也不执行类型分析。通过普通值 import 导入的类型会保留为运行时 ESM 请求,而 TypeScript 的 `export =` 会变成 CommonJS 赋值,而不是 ESM default export。因此,源码图必须显式使用仅类型导入和原生 ESM 导出;resolve hook 无法修复不兼容的源码语法。
|
||||
|
||||
Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin 不经过 TypeScript import 分析,其解析方 manifest 可能漏掉所需依赖。Cordis Loader 会记录插件 import 错误,并留下没有 fiber 的 entry,但不会让启动本身失败;配置中的拼写错误因此可能得到退出码为 0 的残缺应用。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh` 的 TUI、Web 和无头源码启动使用 `node --experimental-transform-types`,由 Node 完成 TypeScript 转换,不加载 `tsx` 或 esbuild。`bin/dsh`、根级 `dsh`/TUI/Web demo 以及 Code Mode TUI 都进入同一条 `apps/cli/src/bin.ts` 启动链路。测试与 e2e 启动器保留各自现有策略,构建后的 `lib/bin.js` 继续由普通 Node 运行。
|
||||
|
||||
`scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。
|
||||
|
||||
只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/cordis.yml` 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。
|
||||
|
||||
`verify-cordis-config` 对这两个解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。
|
||||
|
||||
Loader 完成结算后,共享的 `dsh-app-boot` 会检查每个已启用但没有 fiber 的 entry,并以 `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved` 拒绝启动,同时列出全部加载失败的插件。该诊断位于应用层,不改变 vendor 中 Loader 的启动行为。
|
||||
|
||||
Node-compatible TypeScript 是这项源码启动契约的一部分。vendor 中的 Cordis、Loader、Include、HMR(热模块替换)和 Schemastery 使用 `import type` 标记会被擦除的导入。Schemastery 使用原生 ESM default export 并声明 `type: module`;其 `.mjs` 和 `.cjs` 构建产物分别保留现有的 ESM default export 行为和 `require()` 返回可调用值的行为。这些差异记录在 `vendor/README.md` 中;没有为 vendor 中的框架新增运行时行为。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**继续使用 `tsx`。** 不采用,因为 `tsx`/esbuild 会继续负责 TypeScript 转换,本启动链路无法因此证明 Node 原生转换可用。
|
||||
|
||||
**让源码入口通过包导出加载构建后的 `lib/`。** 不采用,因为这会混合 source plane 与 artifact plane;零构建开发启动可能读取陈旧产物或直接失败。
|
||||
|
||||
**无条件应用根 tsconfig `paths`。** 不采用,因为这会让未声明的跨包 import 和 Cordis 插件继续成功解析,从而掩盖 manifest 与实际运行图之间的不一致。
|
||||
|
||||
**在自定义 loader 内转换 import。** 不采用,因为感知类型的源码改写会重新引入编译器式转换,并让 loader 而非 Node 负责执行 TypeScript。使签入仓库的源码兼容 Node,可以让启动边界保持显式。
|
||||
|
||||
## 后果
|
||||
|
||||
- TUI/无头界面保留零构建源码回路,Web 仍会在启动 CLI 源码入口前构建前端产物。TypeScript 语法只经过 Node 原生转换;仅处理 URL 的 loader 使用 checkout 根目录的开发依赖,不增加 CLI 运行时依赖。
|
||||
- workspace package import 和 Cordis 配置依赖都必须在解析方 manifest 中明确声明;静态门禁防止配置先于依赖落地,额外依赖不构成错误。
|
||||
- 插件 import 失败不再留下退出码为 0 的残缺应用;最终错误同时说明 Cordis 启动失败及具体插件名,Loader 的原始错误仍会保留在更早的日志中。
|
||||
- CLI 源码图中的 vendor 源码必须与 Node 的 transform-types 模块语义兼容;本地修改记录明确了上游同步义务。
|
||||
- CI 的 `lib` 模式、测试/e2e 启动器和其他示例启动器保留各自现有策略;该原生源码 loader 只覆盖 `dsh` CLI 应用链路。
|
||||
+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/architecture/2026-07-28-identified-immutable-message-values.md
|
||||
2026-07-28-identified-immutable-message-values.md: cdb0f1aadc4796b5aa0642a3994d3e3e4ab67bd9
|
||||
2026-07-28-identified-immutable-message-values.zh.md: 3e1732cb5b7f49fb9349b2e1790cf5b3ec1474be
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Agent Note: Create every message as an identified immutable value
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-identified-immutable-message-values.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness had several message-shaped representations with different identity rules. Agent input acquired an inbox correlation id only when the loop accepted it, while durable user messages, assistant messages, tool results, and model-request messages could have no identity. Prompt admission therefore sat between creation and identity, and equivalent content was copied across live events, durable events, and model requests without one value that named the message throughout its lifetime.
|
||||
|
||||
This made identity a routing side effect rather than a message invariant. Producers could not refer to a message before calling the agent, prompt hooks received content and source separately, and later projections had to reconstruct a message while deciding whether an id existed. Immutability also began at different boundaries: some inputs were frozen by the loop, some only by session append, and provider-produced assistant output used a separate provenance-bearing shape.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-llm` owns one `Message` value with required `id`, `role`, `content`, and `source`. `MessageId` is opaque and shared by user, assistant, and tool-result messages. A message receives its id at creation, before routing, prompt admission, durable append, or request projection. The same id survives every representation boundary.
|
||||
|
||||
`createMessage(input)` is the canonical role-generic creation boundary. It mints a `MessageId`, detaches the supplied role, content, and source, and deep-freezes the complete value before returning it. `createUserMessage({ content, source })` fixes the user role for prompt and context producers. `createAssistantMessage({ content, source })` fixes both the assistant role and the model source kind, so model-output producers supply only content and model provenance. All creation helpers exclude an input id so callers cannot accidentally present creation as import. `freezeMessage(message)` is the separate import or transformation boundary: it detaches and deep-freezes a message whose identity already exists, without minting a replacement.
|
||||
|
||||
The helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
|
||||
|
||||
The `Agent` interface accepts a complete `UserMessage`. `send`, `followup`, `steer`, and `inject` never allocate or return identity; they freeze an imported value whose id the caller already holds. Prompt admission receives that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id.
|
||||
|
||||
Durable message-producing events store complete messages. `user/message` stores its `UserMessage` directly; `assistant/message`, `tool/result`, and `steering/message` wrap their role-specialized message beside event-local position, usage, failure, or presentation facts. Session derivation returns those frozen values instead of reconstructing anonymous messages. Assistant assembly creates a model-sourced message when a response completes, and tool execution creates a tool-sourced message when a result is committed.
|
||||
|
||||
Any operation that changes only the representation of an existing semantic message preserves its id and returns another frozen value. An operation that creates a new semantic message mints a new id. Compaction content rewrites therefore preserve the rewritten tool-result identity, while a summary checkpoint is a new message.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep ids optional on the base message.** This would minimize fixture migration and allow provider or persistence shapes to remain anonymous. It would also preserve the original ambiguity: every consumer would need to branch on whether identity exists, and no type would prove that admission, logging, or projection retained it.
|
||||
|
||||
**Let `Agent.send()` allocate the id.** This keeps identity scoped to inbox correlation but makes the agent call the earliest point at which a producer can name its own message. Prompt construction, UI attachments, and synchronous enqueue/discard coordination then need content matching or an out-of-band token before `send()` returns.
|
||||
|
||||
**Let each durable event allocate a new id.** This gives persisted messages identities but deliberately breaks correlation with the live input and makes replayed requests appear to contain different messages. Identity belongs to the semantic value, not to each envelope that carries it.
|
||||
|
||||
**Freeze only at agent or session admission.** This avoids a creation helper but leaves an identified mutable interval in which caller code can change the meaning associated with an id. The decision makes “has an id” and “is an immutable snapshot” coincide.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every message producer must choose creation or import explicitly, and tests construct complete values rather than partial content/source records. UUID generation moves outward to the first semantic creation point, so deterministic fixtures that provide an existing id use `freezeMessage()` instead of `createMessage()`.
|
||||
|
||||
Live inbox events, durable events, derived history, and model requests can correlate one message without content equality or envelope-specific ids. Prompt admission and UI attachment cleanup can compare `MessageId` before a turn exists. Deep freezing prevents a producer, hook, or observer from changing the value after identity is established.
|
||||
|
||||
The shared representation removes the old `UserMessageData`/`AgentMessage` split and folds provider provenance into typed message sources. Event envelopes still own facts that are not message semantics, such as turn and step position, token usage, internal tool failure identity, and presentation metadata.
|
||||
|
||||
The message and helper unit tests pin immediate identity, detachment, deep immutability, and preservation of an imported id. Agent-loop tests pin identity across admission, inbox lifecycle, durable append, content rewriting, and cancellation; session tests pin frozen derivation and identity-preserving replacement.
|
||||
|
||||
## Related
|
||||
|
||||
- [Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message](2026-07-22-unified-send-and-coalesced-user-messages.md) — this note supersedes its input-representation and agent-assigned-id details while retaining its routing decision.
|
||||
- [Reconstructable requests](2026-07-05-reconstructable-requests.md) — the session log remains the authority for every model-visible input.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Agent Note: 将每条消息创建为带标识的不可变值
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-identified-immutable-message-values.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 曾存在多种形似消息的表示,各自采用不同的标识规则。agent(智能体)输入只有在 loop 接受后才会取得 inbox 关联 id,而持久用户消息、assistant 消息、工具结果和模型请求消息都可能没有标识。因此,提示词准入介于创建消息与建立标识之间;等价内容会在实时事件、持久事件和模型请求之间复制,却没有一个值能在消息的整个生命周期中标识它。
|
||||
|
||||
这使标识成为路由的副作用,而不是消息不变量。生产方无法在调用 agent 前引用一条消息,提示词钩子会分别接收内容和来源,后续投影则必须一边重建消息,一边决定 id 是否存在。不可变性也从不同边界开始:部分输入由 loop 冻结,部分直到会话追加时才冻结,提供方产生的 assistant 输出则使用另一种携带溯源信息的形状。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-llm` 持有唯一一种 `Message` 值,其 `id`、`role`、`content` 和 `source` 均为必填。`MessageId` 是不透明标识,由用户消息、assistant 消息和工具结果消息共享。消息在创建时就会获得 id,早于路由、提示词准入、持久追加或请求投影。同一个 id 会跨越每个表示边界。
|
||||
|
||||
`createMessage(input)` 是角色通用的规范创建边界。它会生成 `MessageId`,将输入的角色、内容和来源与输入分离,并在返回完整值前将其深度冻结。`createUserMessage({ content, source })` 为提示词和上下文生产方固定 user 角色。`createAssistantMessage({ content, source })` 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容和模型溯源信息。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把创建表达为导入。`freezeMessage(message)` 是独立的导入或转换边界:它会将已有标识的消息与输入分离并深度冻结,不会生成替代标识。
|
||||
|
||||
这些辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整契约只依赖该词汇。`createToolResultMessage()` 与其他创建辅助函数同属此处:它使用同一个工具调用 id,将工具来源与确切的 user-role 工具结果块耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
|
||||
|
||||
`Agent` 接口接收完整的 `UserMessage`。`send`、`followup`、`steer` 和 `inject` 绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。提示词准入会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。
|
||||
|
||||
产生持久消息的事件会存储完整消息。`user/message` 直接存储其 `UserMessage`;`assistant/message`、`tool/result` 和 `steering/message` 则将各自角色专用的消息与事件本地的位置、用量、失败或呈现事实包装在一起。会话派生会返回这些冻结值,而不是重建匿名消息。assistant 组装会在响应完成时创建模型来源的消息,工具执行会在提交结果时创建工具来源的消息。
|
||||
|
||||
仅改变已有语义消息表示的操作会保留其 id,并返回另一个冻结值。创建新语义消息的操作则会生成新 id。因此,压缩(compaction)内容改写会保留被改写工具结果的标识,而摘要检查点是一条新消息。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**让基础消息的 id 保持可选。** 这能减少 fixture(测试前置数据)迁移,并允许提供方或持久化形状继续保持匿名,但也会保留原有歧义:每个消费方都必须根据标识是否存在执行分支,且没有任何类型能证明准入、记录或投影保留了标识。
|
||||
|
||||
**让 `Agent.send()` 分配 id。** 这会将标识限定在 inbox 关联范围内,却也会让 agent 调用成为生产方可以标识自身消息的最早时机。这样一来,在 `send()` 返回前,提示词构造、UI 附件和同步入队/丢弃协调都需要进行内容匹配,或使用带外 token。
|
||||
|
||||
**让每个持久事件分配新 id。** 这能为持久消息提供标识,却会有意切断它与实时输入的关联,并让回放请求表现得像包含了不同消息。标识属于语义值,而不是承载它的每个封装。
|
||||
|
||||
**只在 agent 或会话准入时冻结。** 这能省去创建辅助函数,却会留下一个带标识但可变的时间区间,调用方代码可以在这段时间内改变该 id 所关联的含义。本决策让「拥有 id」与「是不可变快照」同时成立。
|
||||
|
||||
## 后果
|
||||
|
||||
每个消息生产方都必须显式选择创建或导入,测试也会构造完整值,而不是不完整的内容/来源记录。UUID 的生成会前移至最初的语义创建点,因此提供已有 id 的确定性 fixture 会使用 `freezeMessage()`,而不是 `createMessage()`。
|
||||
|
||||
实时 inbox 事件、持久事件、派生历史和模型请求可以关联同一条消息,无需比较内容或使用封装专用 id。提示词准入和 UI 附件清理可以在轮次存在之前比较 `MessageId`。深度冻结可以防止生产方、钩子或观察方在标识建立后更改消息值。
|
||||
|
||||
共享表示移除了旧的 `UserMessageData`/`AgentMessage` 划分,并将提供方溯源信息纳入带类型的消息来源。事件封装仍持有不属于消息语义的事实,例如轮次与步骤位置、token 用量、内部工具失败标识和呈现元数据。
|
||||
|
||||
消息和辅助函数的单元测试会固定即时标识、输入分离、深度不可变性,以及导入 id 的保留。agent loop 测试会固定标识跨越准入、inbox 生命周期、持久追加、内容改写和取消的行为;会话测试会固定冻结派生和保留标识的替换行为。
|
||||
|
||||
## 相关
|
||||
|
||||
- [统一通过 send(target × wakeup) 交付 agent 消息,并将注入上下文合并到 user/message](2026-07-22-unified-send-and-coalesced-user-messages.md)——本记录取代其中的输入表示和由 agent 分配 id 的细节,同时保留其路由决策。
|
||||
- [可重建的请求](2026-07-05-reconstructable-requests.md)——会话日志仍是每项模型可见输入的权威来源。
|
||||
+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-28-load-pre-identity-session-messages.md
|
||||
2026-07-28-load-pre-identity-session-messages.md: 2901527658421b37576bdf5b49e66829104a3b41
|
||||
2026-07-28-load-pre-identity-session-messages.zh.md: 61d57ac9f3318299b63faa659b6d155e8e89fae3
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: Load sessions persisted before message identity
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-load-pre-identity-session-messages.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The identified immutable message change replaced four durable event payloads with complete message values. Existing v0 JSONL and SQLite sessions still held the immediately preceding shapes: direct `content`/`source` on user and steering events, `content`/`provenance` on assistant events, and `callId`/`content`/`isError` on tool results. Their headers still matched `SESSION_FORMAT_VERSION`, but current-shape validation rejected them before resume could construct a live `Session`.
|
||||
|
||||
Changing the message representation without a version bump made those logs indistinguishable at the header level from current v0 logs. The runtime needs a narrow import rule that restores data created by the supported first-party backends without weakening validation for unrelated obsolete or malformed events.
|
||||
|
||||
## Decision
|
||||
|
||||
`PersistenceCoordinator` normalizes the four exact pre-identity message payloads after backend decoding and before current message validation. It wraps their existing semantic fields in the current role-specific message shape and assigns `legacy-message:<session-id>:<event-seq>` as the deterministic imported `MessageId`. A legacy `tool/result` content replacement inherits the imported id of its replacement target, preserving the current content-only rewrite invariant.
|
||||
|
||||
The same normalization runs for `load`, `inspect`, an ownerless loaded state claiming its live session, and HMR prefix adoption. Prefix comparisons therefore compare the live current-shape seed with the same normalized stored view. Current-looking wrappers with missing or invalid fields are not repaired, and unsupported event vocabulary, request headers, versions, and surface relations retain their existing rejection paths.
|
||||
|
||||
The upgrade is read-only. Stored legacy records remain unchanged; a resumed session appends only current-shape events after them. Deterministic identities make repeated loads and a mixed legacy/current log reproduce the same message ids without a backend-specific rewrite transaction.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Reject the logs under the pre-release compatibility stance.** This is the default for unrelated v0 churn, but it strands real first-party sessions even though every old field maps unambiguously to the current message representation.
|
||||
|
||||
**Rewrite the complete stored log in place.** This would canonicalize the artifact but violate the append-only storage contract, require separate atomic replacement mechanisms for JSONL and SQLite, and expand a read compatibility fix into a migration system.
|
||||
|
||||
**Mint random ids on each load.** The messages would satisfy the type shape but lose stable identity across inspect, resume, restart, and mixed legacy/current appends.
|
||||
|
||||
## Consequences
|
||||
|
||||
Pre-identity JSONL and SQLite sessions resume with their original message content, sources, provider provenance, tool correlation, errors, metadata, and surface replacements. The returned events are otherwise indistinguishable from current imported message snapshots and remain deeply frozen.
|
||||
|
||||
This is one explicit same-version import exception, not a general v0 compatibility layer. Adding another exception requires another complete, unambiguous mapping at the persistence boundary; malformed current data continues to fail rather than being guessed into validity. The shared coordinator contract exercises the upgrade against the in-memory reference, JSONL, and SQLite backends, including deterministic reload and tool-result replacement identity.
|
||||
|
||||
## Related
|
||||
|
||||
- [Create every message as an identified immutable value](../architecture/2026-07-28-identified-immutable-message-values.md) — owns the current message identity and immutability contract.
|
||||
- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns the append-only backend and resume boundary.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: 加载消息标识机制引入前持久化的会话
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-load-pre-identity-session-messages.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
带标识的不可变消息变更将四种持久事件载荷替换为完整消息值。现有的 v0 JSONL 和 SQLite 会话仍保留紧邻该变更之前的形状:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些会话的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前形状验证会拒绝它们,导致恢复流程无法构造实时 `Session`。
|
||||
|
||||
消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前的 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的第一方后端所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。
|
||||
|
||||
## 决策
|
||||
|
||||
`PersistenceCoordinator` 会在后端解码之后、当前消息验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息形状,并为其分配确定性的导入 `MessageId`:`legacy-message:<session-id>:<event-seq>`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id,从而保持当前仅改写内容的不变量。
|
||||
|
||||
同一项规范化也用于 `load`、`inspect`、无 owner 的已加载状态认领其实时会话,以及 HMR(热模块替换)前缀接管。因此,前缀比较会将实时的当前形状 seed 与同一份规范化存储视图进行比较。看似当前形状、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。
|
||||
|
||||
这项升级只发生在读取时。存储中的旧版记录保持不变;会话恢复后,只会在其后追加当前形状的事件。确定性标识使重复加载以及新旧形状混合的日志无需执行后端专用的重写事务,也能复现相同的消息 id。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**按照预发布兼容性立场拒绝这些日志。** 这是处理其他 v0 形状变动的默认方式,但即使每个旧字段都能明确映射到当前消息表示,它仍会导致真实的第一方会话无法恢复。
|
||||
|
||||
**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储契约,还需要为 JSONL 和 SQLite 分别实现原子替换机制,并将一次读取兼容性修复扩大为迁移系统。
|
||||
|
||||
**每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。
|
||||
|
||||
## 后果
|
||||
|
||||
消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、提供方溯源信息、工具关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。
|
||||
|
||||
这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享协调器契约会针对内存参考实现、JSONL 和 SQLite 后端验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。
|
||||
|
||||
## 相关
|
||||
|
||||
- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.md):该记录负责当前的消息标识与不可变性契约。
|
||||
- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.md):该记录负责仅追加后端与恢复边界。
|
||||
+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-28-themed-scrollbars-and-reserved-gutter.md
|
||||
2026-07-28-themed-scrollbars-and-reserved-gutter.md: 38228c868bb00210118e8110feb722fb81d0d56c
|
||||
2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 9fafe1faa9303b5e2e23a1b3064904f71494026d
|
||||
@@ -0,0 +1,84 @@
|
||||
# Agent Note: The scrollbar tokens get their consumer, and the workspace list reserves its gutter
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`design-platform.css` declares four `--dsw-alias-scrollbar-*` tokens (`bg-l1`, `bg-l2`, `hover-l1`, `hover-l2`) in both palettes, and no rule anywhere in the client read them. A defined token with no consumer is not a theme: every scrolling region rendered the user agent's own scrollbar, which knows nothing about the palette, so the dark theme showed a light native bar against dark surfaces.
|
||||
|
||||
The visible symptom that surfaced the gap was elsewhere. The workspace browser's session list (`.list` in `WorkspaceBrowser.module.css`) is the sidebar's only scrolling region, and each row's trailing content sits flush against the row's 8px right padding — `.time` in `rows/Rows.module.css` is `flex: none`, as are the action buttons that replace it on hover. An overlaid scrollbar therefore painted on top of the relative timestamp. Reserving space in that one list would have left the bar itself unthemed, so the two halves are one change.
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/client/ui-theme/src/styles/scrollbar.css` is the sole consumer of the four tokens, and the fifth ui-theme sheet in the shell's import chain (`packages/client/web/src/base.css`). It follows `design-platform.css` there because it reads that sheet's tokens.
|
||||
|
||||
The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-alias-*` tokens on `body`, with the dark overrides on `body[data-ds-dark-theme]`, and custom properties inherit only downward; an `html` rule resolves them to the guaranteed-invalid value, at which point `scrollbar-color` computes to `auto` and no theming happens at all.
|
||||
|
||||
`scrollbar-width` and `scrollbar-color` are declared on `body, body *` rather than once at the top. Inheritance would pass down the color already substituted at `body`, so a descendant rebinding the indirection could not change its own scrollbar; re-declaring makes each element substitute the variable as it sees it. `scrollbar-width` is not an inherited property in the first place, so it needs the per-element declaration regardless. The `::-webkit-scrollbar*` pseudo-elements are likewise not inherited and are matched unscoped.
|
||||
|
||||
The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading.
|
||||
|
||||
Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. Eight surfaces rebind today: the command popup, the slash menu, the model-select panel, the settings panel, the shared `ui-primitives` menu card, the composer input card, the question composer card, and the todo panel. Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls.
|
||||
|
||||
The last four were missed in the first implementation and found in review, which is why the rebinding contract is now checked mechanically rather than by inspection: a sheet that scrolls somewhere and paints an elevated surface somewhere must rebind.
|
||||
|
||||
The elevated set is resolved from the palette's own dark elevation ladder — the surface tokens whose dark value lands on `bg-layer-2` or `bg-layer-3`, which is the step the l1/l2 split encodes. Deriving it instead from the sheets that already rebind was the first attempt and is unsound: such a set can only confirm what someone already remembered, and a surface nobody has rebound yet — exactly the case the check exists for — defines itself as unelevated. `--dsw-specific-tip` proved it, resolving to the menu surface's rung while the todo panel scrolled on it unrebound and the derived check stayed green.
|
||||
|
||||
Scope is by token family, not by geometry: only `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface. `--dsw-alias-button-*`, `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules, and CSS text does not express which contains which.
|
||||
|
||||
The track and the corner stay transparent, so the thumb reads against whatever surface scrolls under it; only the thumb and its hover state carry a token color.
|
||||
|
||||
`.list` declares `scrollbar-gutter: stable`, which keeps the bar beside the rows instead of on top of them. `stable` rather than `auto` because `auto` reserves the gutter only while the list actually overflows: expanding a workspace group would then shift every row horizontally at the moment it starts scrolling. The reservation is unconditional and the rows never move.
|
||||
|
||||
The gutter and the sheet's `::-webkit-scrollbar` width are jointly necessary against an overlay scrollbar, which is the configuration where the symptom exists at all. Measured on the running app by deleting each from the live cascade with the other left in force: either deletion alone takes the list's band from 8 to 0. The gutter states that space be reserved, and the pseudo-element width is what makes chromium treat the bar as occupying layout space rather than floating over the content. Neither half of this change is therefore optional for the reported bug, which is a second reason the two halves ship together.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Per-module `::-webkit-scrollbar` rules in each scrolling component sheet.** Rejected: the client has thirteen scrolling containers across nine packages, every one would carry the same block, and the fourteenth would ship unthemed with nothing failing. A skin driven by design tokens belongs in the package that owns the tokens.
|
||||
|
||||
**An opt-in utility class that each scroll container adds.** Same duplication removed, but the failure mode stays: a new scroll container is themed only if its author remembers the class, and the omission is invisible in review. The `body, body *` form has no opt-in step to forget; a container that genuinely wants a different bar overrides the indirection, which is the same mechanism elevated surfaces use.
|
||||
|
||||
**Bind the properties on `html`.** The natural place for a document-wide skin, and it fails measurably: with the rule on `html` a scroll container computes `scrollbar-color: auto` in chromium, because the alias tokens are not in scope there.
|
||||
|
||||
**Declare the properties once and let them inherit.** Fewer matched elements, and it breaks the rebinding contract — inheritance carries the substituted color, not the variable reference, so an elevated surface could not retint its own scrollbar. It is also incomplete on its own terms, since `scrollbar-width` does not inherit.
|
||||
|
||||
**Declare the standard properties and the pseudo-elements unconditionally, without the `@supports` gate.** This is what the change originally shipped, and review caught it. Measured in chromium on probe elements with `scrollbar-gutter: stable` so the band is observable: an 8px `::-webkit-scrollbar` alone reserved a 30px band (the sheet's width plus the UA's buttons), and adding `scrollbar-width: thin` to the same element dropped it to the 10px `thin` reserves — the pseudo-element rules were being discarded, not merged. Every `::-webkit-scrollbar-thumb:hover` rule went with them, so both hover tokens and all four elevated surfaces' hover rebinds were dead code on the engine most users run.
|
||||
|
||||
**Gate the WebKit rules too, behind `@supports selector(::-webkit-scrollbar)`.** Symmetrical to read, and wrong in one direction: it would hide the rules from an engine that implements the pseudo-elements but not `selector()`, which is the pre-16.4 Safari the ungated form serves correctly. Unknown selectors are already dropped, so the gate adds no protection to pay for that.
|
||||
|
||||
**Pad the rows instead of reserving the gutter (extra right padding on `.list`, or moving `.time` inward).** Rejected: padding applies whether or not a bar is present, so it costs horizontal room in the common short-list case, and it fixes exactly one container while leaving every other scrolling region's content under its bar.
|
||||
|
||||
**`scrollbar-gutter: auto` on `.list`.** The reservation appears when the list overflows, which is when the bar exists. Rejected because the sidebar's lists grow and shrink as groups expand, so the reservation would appear and disappear under the user's cursor and shift the rows with it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every scroll container in the client draws the themed thumb: `rgb(229, 229, 229)` on a light base surface, `rgb(60, 60, 61)` on a dark one, and `rgb(84, 85, 87)` for a dark elevated surface that rebinds to the l2 pair.
|
||||
- The two renderings are separately specified, so a change to the thumb's geometry or hover behavior has to be made twice — once in `scrollbar-width`/`scrollbar-color`, once in the pseudo-elements. Routing both through the indirection pair confines that duplication to the properties Firefox and WebKit do not share.
|
||||
- The hover tokens (`--dsw-alias-scrollbar-hover-l1`/`-l2`) render only on the pseudo-element path. Firefox states one thumb color through `scrollbar-color` and derives its own hover treatment, so a design change to the hover colors is visible in Chromium and Safari and not in Firefox. This is a limit of `scrollbar-color`, not of the sheet.
|
||||
- `body *` matches every element, for two properties whose effect the user agent already limits to elements that actually scroll. The cost is a broad selector; the alternative was a rebinding contract that does not work.
|
||||
- The workspace list is permanently narrower by the reserved band, at every list length. That is the trade the fix buys: stable row geometry instead of a timestamp that is legible only while the list is short.
|
||||
- There is no track token in the palette, so a design that later wants an opaque track needs a new alias token rather than a literal color in this sheet.
|
||||
|
||||
## Testing
|
||||
|
||||
Three unit specs read the CSS text on disk. `ui-theme/tests/scrollbar-styles.spec.ts` scans the scrollbar token set out of `design-platform.css` rather than hardcoding it, so adding, renaming, or dropping a token moves the assertions with it, and checks that every token has a consumer and that each elevated surface rebinds a complete pair. It also pins the path split by source offset: the standard properties inside the gate block, the `::-webkit-scrollbar*` rules and every read of the hover indirection outside it. That split needs an offset assertion because the spec's rule parser flattens through at-rules, so a gate deleted or a declaration moved across it leaves every other assertion in the file green.
|
||||
|
||||
`apps/web/tests/sidebar-scrollbar.e2e.ts` covers the facts only a real engine reports: the reserved band width, and which rendering path the engine took. It needs no model calls — the list only has to overflow — so it seeds cold sessions from an existing committed fixture read-only.
|
||||
|
||||
That scenario also commits a golden, `snapshots/sidebar-scrollbar/geometry.expected.md`, holding the resolved scrollbar style and geometry in both palettes. The aria goldens the other web scenarios commit cannot carry a CSS-only change: it alters no DOM and no accessible name, so their normalized trees are byte-identical with and without it. Recording the resolved values instead makes an unintended shift in thumb colour, band width, or rendering path a reviewable diff rather than a threshold someone has to reason about. Absolute coordinates are deliberately excluded — `timeRight` and the two edges depend on the sidebar's laid-out width and on font metrics, so committing them would produce a fixture that has to be re-recorded per platform and would document the platform rather than the change. What is recorded is the band, the overlap, and two orderings, each a difference or a comparison that survives any layout preserving the reservation.
|
||||
|
||||
Confirmed in headless chromium on the built client by reading computed values, which is what distinguishes a working token chain from a syntactically valid one: a scroll container computes the l1 thumb color in each palette, and a container that rebinds the indirection computes the l2 color, proving the rebind reaches the computed value rather than only the custom property. Firefox was verified the same way for the standard path, including the l1-to-l2 rebind on `scrollbar-color`; headless Firefox reports `scrollbar-width: none` on every element, styled or not, which is a headless artifact rather than an effect of the sheet.
|
||||
|
||||
Two chromium measurement limits shape what the e2e can assert. The gate makes chromium report `scrollbar-width` and `scrollbar-color` as `auto`, so the substituted `scrollbar-color` is no longer the observable — the e2e asserts the `auto` reading deliberately, since a concrete value there would mean the gate leaked and silenced the pseudo-elements. And `getComputedStyle(el, '::-webkit-scrollbar-thumb')` folds in the `::-webkit-scrollbar-thumb:hover` rule, so it reports the hover color at rest and pins neither state; proven by deleting the hover rule through `CSSStyleSheet.deleteRule` in the live page, which flipped that same query from the hover color to the resting one. The e2e therefore reads the resting and hover colors as the indirection variables resolve on the list — one throwaway probe element per variable, because `getComputedStyle` returns a live declaration and a reused probe reports only the last value read — and reads the hover declaration out of the cascade as rule text.
|
||||
|
||||
The gate itself has a negative control at the level it operates on: removing the `@supports` wrapper from the sheet, rebuilding `build:web`, and rerunning the e2e turns the `scrollbar-width: auto` assertion red with `thin`, which is the suppression the gate exists to prevent.
|
||||
|
||||
Headless chromium draws overlay scrollbars, and that is the configuration in which the reported symptom exists, so the e2e reproduces the bug rather than approximating it: against clean master the list's band is 0 and the bar covers 7px of the relative time. A reserved gutter there does not shrink `clientWidth`, so an assertion comparing the time element's right edge against the client-area edge holds with and without the reservation and would pass or fail on the platform's scrollbar style rather than on the declaration under test. The two signals that do separate the states are the `offsetWidth - clientWidth` band and `timeCoveredBy`, the overlap measured against the bar's own width.
|
||||
|
||||
Both are asserted because each catches a different regression, established by mutating one declaration at a time with the other assertions in that test silenced. Removing only the gutter leaves `timeCoveredBy` at 0 — the bar is then 8px and the row's right padding is also 8px, so it abuts the timestamp without covering it — and the band assertion is what fails. Removing the pseudo-element width as well, which is the actual master state, produces the overlap, and `timeCoveredBy` fails at 7. A headed run under xvfb cannot show the symptom in either state, because chromium paints a classic space-consuming bar there and `clientWidth` already excludes it.
|
||||
|
||||
Verifying browser-visible plugin CSS needs a rebuild `pnpm run build:web` does not perform. `WorkspaceBrowser.module.css` never reaches `apps/web/dist`: ui-workspace loads as a runtime plugin and its CSS is inlined into `packages/client/ui-workspace/lib/client.js`, built by that package's own `bundle` script. A negative control that reruns only `build:web` therefore exercises a stale bundle and passes with the declaration removed, which reads as a vacuous test rather than as an invalid control. Rebuild with `pnpm --filter @deepseek-ai/dsh-client-ui-workspace run bundle`, confirm the artifact by grepping `lib/client.js` for the declaration, then `build:web`.
|
||||
|
||||
`test:web` ran `build:web` alone, so every scroll-region or plugin-CSS change hit that trap; it now runs `build` first, which covers `packages/*/*` and so rebuilds the plugin bundles. `check-all` already ordered `build` before `build:web`, so CI was never exposed — only the local script was, which is exactly where a stale-bundle pass is most likely to be believed.
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
# Agent Note: 滚动条 token 有了消费方,工作区列表预留出滚动条空位
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-themed-scrollbars-and-reserved-gutter.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`design-platform.css` 在亮色与暗色两套调色板中都声明了四个 `--dsw-alias-scrollbar-*` token(`bg-l1`、`bg-l2`、`hover-l1`、`hover-l2`),而客户端里没有任何一条规则读取它们。定义了却无人消费的 token 构不成主题:所有滚动区域渲染的都是浏览器自带的滚动条,它对调色板一无所知,因此暗色主题下暗色表面上出现的是一条亮色的原生滚动条。
|
||||
|
||||
暴露这一缺口的可见症状出在别处。工作区浏览器的会话列表(`WorkspaceBrowser.module.css` 中的 `.list`)是侧边栏里唯一的滚动区域,而每一行的尾部内容都紧贴该行 8px 的右内边距——`rows/Rows.module.css` 中的 `.time` 取 `flex: none`,hover 时取代它的操作按钮也是如此。于是覆盖式滚动条会画在相对时间戳之上。只在这一个列表里预留空间,滚动条本身仍然没有主题,因此两部分合为一次变更。
|
||||
|
||||
## 决策
|
||||
|
||||
`packages/client/ui-theme/src/styles/scrollbar.css` 是这四个 token 的唯一消费方,也是壳的导入链(`packages/client/web/src/base.css`)中第五张 ui-theme 样式表。它排在 `design-platform.css` 之后,因为它读取那张样式表的 token。
|
||||
|
||||
规则挂在 `body` 上,而非 `html`。`design-platform.css` 在 `body` 上声明 `--dsw-alias-*` token,暗色覆盖挂在 `body[data-ds-dark-theme]` 上,而自定义属性只向下继承;挂在 `html` 上的规则会把它们解析为 guaranteed-invalid 值,此时 `scrollbar-color` 计算为 `auto`,主题完全不起作用。
|
||||
|
||||
`scrollbar-width` 与 `scrollbar-color` 声明在 `body, body *` 上,而不是只在顶层声明一次。继承传下去的是已经在 `body` 处代入完成的颜色值,因此后代元素重新绑定这层间接变量也无法改变自己的滚动条;逐元素重新声明使每个元素按它自己看到的取值代入变量。`scrollbar-width` 本身就不是可继承属性,无论如何都需要逐元素声明。`::-webkit-scrollbar*` 伪元素同样不继承,因此以不加限定的选择器匹配。
|
||||
|
||||
两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。
|
||||
|
||||
两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。目前有八处抬升表面做了重新绑定:命令浮层、斜杠菜单、模型选择面板、设置面板、`ui-primitives` 共用菜单卡片、输入条卡片、提问组件卡片与待办面板。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。
|
||||
|
||||
后四处在最初的实现里被漏掉、由评审发现,因此重新绑定契约现在由机械检查把关,而不再依赖人工审阅:一张样式表只要在某处滚动、又在某处绘制抬升表面,就必须重新绑定。
|
||||
|
||||
抬升表面集合是从调色板自身的暗色抬升阶梯解析出来的——暗色取值落在 `bg-layer-2` 或 `bg-layer-3` 上的那些表面 token,而这一档正是 l1/l2 之分所编码的层级差。最初的做法是从已经做了重新绑定的样式表反向推导,那是不成立的:这样得到的集合只能确认别人已经记得的部分,而尚无人重新绑定的表面——恰恰就是这项检查存在的理由——会把自己定义成「非抬升」。`--dsw-specific-tip` 证明了这一点:它解析到与菜单表面相同的那一档,待办面板在它上面滚动却没有重新绑定,而推导式的检查依然是绿的。
|
||||
|
||||
判定范围依据 token 家族而非几何形状:只有 `--dsw-alias-bg-*` 与 `--dsw-specific-*` 表述的是表面。`--dsw-alias-button-*`、`--dsw-alias-interactive-*` 与 `--dsw-alias-markdown-*` 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则,而 CSS 文本无法表达谁包含谁。
|
||||
|
||||
轨道与两条滚动条相交的角落保持透明,因此滑块是以其下滚动的任何表面为背景被看到;只有滑块及其 hover 状态带 token 颜色。
|
||||
|
||||
`.list` 声明 `scrollbar-gutter: stable`,使滚动条位于行的旁边而非行的上方。取 `stable` 而非 `auto`,因为 `auto` 只在列表确实溢出时才预留空位:那样展开一个工作区分组时,所有行会在列表开始滚动的那一刻发生水平位移。`stable` 的预留是无条件的,行不会移动。
|
||||
|
||||
面对覆盖式滚动条——也就是这个症状唯一存在的那种形态——空位声明与样式表里的 `::-webkit-scrollbar` 宽度是共同必要的。在运行中的应用上实测:保留其中一条、从活的层叠中删掉另一条,任意一次单独删除都会让列表的条带从 8 降到 0。空位声明表述的是「要预留空间」,而伪元素宽度才是让 chromium 把滚动条视为占据布局空间、而不是浮在内容之上的原因。因此对这个 bug 而言,本次变更的两半都不是可选项,这也是两半必须一起交付的第二个理由。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**在每个滚动组件的样式表里各写一份 `::-webkit-scrollbar` 规则。** 之所以否决:客户端共有分布在九个包中的十三个滚动容器,每一个都要带上同一段规则,而第十四个会在没有任何门禁报错的情况下漏掉主题。由设计 token 驱动的皮肤应当归属于拥有这些 token 的包。
|
||||
|
||||
**提供一个工具类,由各滚动容器自行加上。** 重复同样被消除,但失败方式依旧存在:新的滚动容器只有在作者记得加类名时才有主题,而遗漏在评审中看不出来。`body, body *` 这种写法没有需要记住的启用步骤;确实想要不同滚动条的容器可以覆盖间接变量,这与抬升表面使用的机制相同。
|
||||
|
||||
**把这两个属性绑定在 `html` 上。** 这是文档级皮肤最自然的落点,而它的失败是可测量的:规则挂在 `html` 上时,chromium 中滚动容器计算出的 `scrollbar-color` 为 `auto`,因为别名 token 在那个作用域内不存在。
|
||||
|
||||
**只声明一次,靠继承下传。** 匹配的元素更少,但它破坏重新绑定契约——继承携带的是代入后的颜色,而不是变量引用,因此抬升表面无法给自己的滚动条换色。它本身也不完整,因为 `scrollbar-width` 不继承。
|
||||
|
||||
**不加 `@supports` 门禁,无条件同时声明标准属性与伪元素。** 这正是本次变更最初提交的形态,被评审发现。在 chromium 中于带 `scrollbar-gutter: stable`(使条带可观测)的探针元素上实测:单独一条 8px 的 `::-webkit-scrollbar` 预留出 30px 条带(样式表指定的宽度加上浏览器自带的按钮),而给同一元素加上 `scrollbar-width: thin` 后降到 `thin` 所预留的 10px——说明伪元素规则是被丢弃,而不是被合并。全部 `::-webkit-scrollbar-thumb:hover` 规则随之失效,因此两个 hover token 与四处抬升表面的 hover 重新绑定,在多数用户实际使用的引擎上都是死代码。
|
||||
|
||||
**给 WebKit 规则也加门禁,写成 `@supports selector(::-webkit-scrollbar)`。** 读起来对称,但在一个方向上是错的:它会对「实现了伪元素但不支持 `selector()`」的引擎隐藏这些规则,而那正是不加门禁时能被正确服务的 16.4 之前的 Safari。未知选择器本就会被丢弃,因此这道门禁不提供任何能抵偿该代价的保护。
|
||||
|
||||
**改用内边距而不是预留空位(给 `.list` 加右内边距,或把 `.time` 向内移)。** 之所以否决:内边距无论滚动条是否存在都生效,因此在常见的短列表情形下白白占用横向空间;而且它只修好一个容器,其余每个滚动区域的内容仍然压在滚动条之下。
|
||||
|
||||
**给 `.list` 用 `scrollbar-gutter: auto`。** 空位在列表溢出时出现,也就是滚动条存在的时候。之所以否决:侧边栏的列表会随分组展开与收起而伸缩,因此空位会在用户光标之下出现又消失,并带动行一起位移。
|
||||
|
||||
## 后果
|
||||
|
||||
- 客户端的每个滚动容器都绘制带主题的滑块:亮色基础表面为 `rgb(229, 229, 229)`,暗色基础表面为 `rgb(60, 60, 61)`,重新绑定到 l2 的暗色抬升表面为 `rgb(84, 85, 87)`。
|
||||
- 两种渲染分别指定,因此改动滑块的几何或 hover 行为需要改两处:一处在 `scrollbar-width`/`scrollbar-color`,一处在伪元素。让两者都经由这组间接变量,把这份重复限制在 Firefox 与 WebKit 不共用的那些属性上。
|
||||
- hover token(`--dsw-alias-scrollbar-hover-l1`/`-l2`)只在伪元素路径上渲染。Firefox 通过 `scrollbar-color` 只表述一个滑块颜色,其 hover 表现由引擎自行推导,因此对 hover 颜色的设计改动在 Chromium 与 Safari 上可见,在 Firefox 上不可见。这是 `scrollbar-color` 本身的限制,不是这张样式表的限制。
|
||||
- `body *` 匹配所有元素,涉及的两个属性其效果本就被浏览器限制在实际会滚动的元素上。代价是一个覆盖面很宽的选择器;另一种选择是一个不生效的重新绑定契约。
|
||||
- 工作区列表在任何列表长度下都永久少了预留空位那一条宽度。这正是该修复换来的代价:以稳定的行几何,换掉只在列表较短时才可读的时间戳。
|
||||
- 调色板中没有轨道 token,因此日后若设计需要不透明轨道,要新增一个别名 token,而不是在这张样式表里写字面颜色。
|
||||
|
||||
## 测试
|
||||
|
||||
三份单元测试读取磁盘上的 CSS 文本。`ui-theme/tests/scrollbar-styles.spec.ts` 从 `design-platform.css` 中扫描出滚动条 token 集合,而不是把它写死,因此新增、重命名或删除 token 时断言会随之变化;它检查每个 token 都有消费方,且每处抬升表面重新绑定的都是完整的一对。它还以源码偏移量锁定两条路径的划分:标准属性在门禁块之内,`::-webkit-scrollbar*` 规则与每一处对 hover 间接变量的读取都在门禁块之外。这个划分必须用偏移量断言,因为该测试文件的规则解析器会把 at-rule 拉平,所以删掉门禁或把某条声明移到门禁另一侧,文件里其余全部断言仍然是绿的。
|
||||
|
||||
`apps/web/tests/sidebar-scrollbar.e2e.ts` 覆盖只有真实渲染引擎才能报告的事实:预留条带的宽度,以及引擎实际走的是哪条渲染路径。它不需要任何模型调用——列表只要溢出即可——因此以只读方式复用一份既有的已提交 fixture(测试前置数据)来铺入冷会话。
|
||||
|
||||
这个场景还提交了一份 golden(期望产物)`snapshots/sidebar-scrollbar/geometry.expected.md`,记录两套调色板下解析后的滚动条样式与几何。其余 web 场景提交的 aria golden 承载不了纯 CSS 的改动:它不改变任何 DOM、也不改变任何无障碍名称,因此有无这次改动,它们规范化后的树都是逐字节相同的。改为记录解析后的取值,就让滑块颜色、条带宽度或渲染路径的意外变化成为可评审的 diff,而不是一条需要人去推敲的阈值断言。绝对坐标被特意排除——`timeRight` 与两条边缘取决于侧边栏排版后的宽度和字体度量,把它们提交进去会得到一份需要按平台重新录制的 fixture,那记录的是平台而不是这次改动。真正记录下来的是条带、重叠量与两个先后关系,每一项都是差值或比较,因此只要预留仍然成立,任何排版下都不变。
|
||||
|
||||
在构建产物客户端上于 headless chromium 中读取计算值确认,这正是区分「token 链真正生效」与「语法合法」的手段:滚动容器在两套调色板下分别计算出 l1 的滑块颜色,而重新绑定间接变量的容器计算出 l2 的颜色,证明重新绑定作用到了计算值,而不只是作用到自定义属性上。Firefox 的标准属性路径以同样方式做了验证,包含 `scrollbar-color` 上从 l1 到 l2 的重新绑定;headless Firefox 对任何元素(无论是否被样式命中)都报告 `scrollbar-width: none`,这是 headless 的产物,不是这张样式表造成的。
|
||||
|
||||
chromium 上有两处测量限制决定了 e2e 能断言什么。门禁使 chromium 报告的 `scrollbar-width` 与 `scrollbar-color` 都是 `auto`,因此代入后的 `scrollbar-color` 不再是可观测量——e2e 特意断言这个 `auto` 读数,因为此处出现具体值就意味着门禁泄漏、伪元素被静音。另外,`getComputedStyle(el, '::-webkit-scrollbar-thumb')` 会把 `::-webkit-scrollbar-thumb:hover` 规则一并折算进去,因此它在静止态就报告 hover 颜色,两种状态都锁不住;这一点由在运行中的页面里用 `CSSStyleSheet.deleteRule` 删掉 hover 规则得证——同一查询随之从 hover 颜色翻转为静止态颜色。因此 e2e 改为读取那组间接变量在列表上代入后的静止态与 hover 颜色(每个变量用一个一次性探针元素,因为 `getComputedStyle` 返回的是活的声明对象,复用探针只会报告最后一次读到的值),并把 hover 声明当作规则文本从层叠中读出。
|
||||
|
||||
门禁本身在它起作用的层面有反向对照:把样式表中的 `@supports` 包裹去掉、重新 `build:web`、再跑 e2e,`scrollbar-width: auto` 那条断言会以 `thin` 变红,而这正是门禁存在所要阻止的那种静音。
|
||||
|
||||
headless chromium 绘制的是覆盖式滚动条,而这恰好就是被报告症状存在的那种形态,因此这个 e2e 复现的是这个 bug 本身,而不是它的近似:在干净的 master 上,列表条带为 0,滚动条盖住相对时间 7px。其中预留空位不会缩小 `clientWidth`,因此把时间元素右边缘与内容区右边缘做比较的断言在有无预留的两种状态下都成立,它的通过或失败取决于平台的滚动条样式,而不是取决于被测的那条声明。真正能区分两种状态的两个量是 `offsetWidth - clientWidth` 条带,以及以滚动条自身宽度为基准量出的重叠量 `timeCoveredBy`。
|
||||
|
||||
两者都要断言,因为各自捕捉的是不同的回归;这一点通过每次只改动一条声明、并把同一个测试里的其余断言静音来确定。只删掉空位声明时 `timeCoveredBy` 仍为 0——此时滚动条是 8px,而行的右内边距也是 8px,于是它紧贴时间戳但并未盖住——失败的是条带那条断言。再把伪元素宽度也删掉(这才是 master 的真实状态)才会产生重叠,此时 `timeCoveredBy` 以 7 变红。在 xvfb 下的有头运行无论哪种状态都看不到这个症状,因为 chromium 在那里画的是经典占位滚动条,`clientWidth` 本来就已经把它排除了。
|
||||
|
||||
验证浏览器可见的插件 CSS 需要一次 `pnpm run build:web` 并不执行的重建。`WorkspaceBrowser.module.css` 从不进入 `apps/web/dist`:ui-workspace 以运行时插件方式加载,其 CSS 内联进 `packages/client/ui-workspace/lib/client.js`,由该包自己的 `bundle` 脚本构建。因此只重跑 `build:web` 的反向对照实际测的是旧产物,去掉声明后仍会通过,看起来像测试无效,实际是对照无效。正确做法是先 `pnpm --filter @deepseek-ai/dsh-client-ui-workspace run bundle`,用 grep 在 `lib/client.js` 中确认该声明确实存在或消失,然后再 `build:web`。
|
||||
|
||||
`test:web` 原先只运行 `build:web`,因此任何滚动区域或插件 CSS 的改动都会碰到这个陷阱;现在它先运行 `build`,而 `build` 覆盖 `packages/*/*`,从而会重建各插件产物。`check-all` 本来就把 `build` 排在 `build:web` 之前,所以 CI 从未受影响——受影响的只有本地脚本,而这恰恰是「产物过期却通过」最容易被当真的地方。
|
||||
@@ -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-pnpm-setup-runner-isolation.md
|
||||
2026-07-29-pnpm-setup-runner-isolation.md: 743535d0394cbea0374c412ba6968910ce858de4
|
||||
2026-07-29-pnpm-setup-runner-isolation.zh.md: 1e51070f88dead17b9d3f5625e337c558786aba2
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Isolate pnpm setup per GitHub Actions runner
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773), three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression.
|
||||
|
||||
## Decision
|
||||
|
||||
Every `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md).
|
||||
|
||||
[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Serialize failover jobs.** Rejected because it discards the six-runner pool's intended parallelism and turns an action-local directory collision into queueing across otherwise independent jobs.
|
||||
|
||||
**Assign a separate Unix user to every runner service.** This would also separate `HOME`, but it moves the invariant into external VM provisioning and complicates ownership of the deliberately shared persistent pnpm store. The workflow already receives a runner-private temporary directory.
|
||||
|
||||
**Retry failed setup steps.** Rejected because retries only reduce the observed collision rate; another concurrent setup can remove the same shared directory again.
|
||||
|
||||
## Consequences
|
||||
|
||||
pnpm's executable installation is ephemeral and isolated per runner, while package downloads still use the configured persistent or cached store. Hosted jobs use the same explicit destination without changing cache policy. The workflow carries three extra configuration lines per setup step, and the regression test must be updated only if pnpm provisioning intentionally moves to a different isolation mechanism.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: 按 GitHub Actions runner 隔离 pnpm 设置
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-pnpm-setup-runner-isolation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在 [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773) 中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。
|
||||
|
||||
## 决策
|
||||
|
||||
[主 CI 工作流](../../../../.github/workflows/ci.yml)中的每个 `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 提供机制决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md)。
|
||||
|
||||
[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**串行执行故障切换作业。** 否决:这会牺牲由六个 runner 组成的池所具备的预期并行能力,并把 action 内部的目录冲突变成原本相互独立作业之间的队列等待。
|
||||
|
||||
**为每个 runner 服务分配独立的 Unix 用户。** 这同样能够隔离 `HOME`,但会把该不变量转移到外部 VM 配置中,并使刻意共享的持久 pnpm store 的所有权变得复杂。工作流已经获得 runner 专属临时目录。
|
||||
|
||||
**重试失败的设置步骤。** 否决:重试只能降低观测到的冲突发生率;另一个并发设置过程仍可能再次删除同一个共享目录。
|
||||
|
||||
## 后果
|
||||
|
||||
pnpm 可执行文件采用临时安装,并按 runner 隔离;包下载仍使用已配置的持久或缓存 store。托管作业使用相同的显式目标目录,不改变缓存政策。工作流中的每个设置步骤因此增加三行配置;只有在 pnpm 提供机制有意迁移到另一种隔离机制时,才需要更新回归测试。
|
||||
@@ -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-29-todo-write-tool.md: 760373d64f462e3717a174d5793e6d47ab76b0b4
|
||||
2026-06-29-todo-write-tool.zh.md: fd29e6049e6a729c2c7e78860ad7c065422da60e
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-29-todo-write-tool.md
|
||||
2026-06-29-todo-write-tool.md: 288932f641a37c13ea6beeb069ac360c4a8447c1
|
||||
2026-06-29-todo-write-tool.zh.md: 03dae6328e5c7f4379694ab01db3434b4469826c
|
||||
@@ -18,7 +18,7 @@ The model sends the entire list every call; the new list replaces the old (last-
|
||||
|
||||
### State on the session log, not a service
|
||||
|
||||
The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the current list from the latest `todo/write`, with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that. (Full-log consumers get this reconstruction outright; the web client's paged window gets it from the tail history page's host-computed projection — see the [web todo display note](2026-07-23-web-todo-display.md).)
|
||||
The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the standing plan from the latest `todo/write` that is not followed by a later `turn/start` ([plan strip lifetime](2026-07-28-todo-plan-clears-on-next-turn.md)), with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that. (Full-log consumers get this reconstruction outright; the web client's paged window gets it from the tail history page's host-computed projection — see the [web todo display note](2026-07-23-web-todo-display.md).)
|
||||
|
||||
### NOT a surface event
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结
|
||||
|
||||
### 状态在会话日志上,而非服务
|
||||
|
||||
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从最新的 `todo/write` 重新推导当前列表,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。(全量 log 消费者直接获得这份重建;web 客户端的分页窗口则从尾页 history 携带的 host 计算投影获得——见 [web todo 展示 Note](2026-07-23-web-todo-display.md)。)
|
||||
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从「其后没有更晚 `turn/start`」的最近一次 `todo/write` 重新推导站立计划([计划条生命周期](2026-07-28-todo-plan-clears-on-next-turn.md)),无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。(全量 log 消费者直接获得这份重建;web 客户端的分页窗口则从尾页 history 携带的 host 计算投影获得——见 [web todo 展示 Note](2026-07-23-web-todo-display.md)。)
|
||||
|
||||
### 不是 surface 事件
|
||||
|
||||
|
||||
@@ -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-06-approval-seam.md: 70ccd4d486ad6e0126fa2eb638a064e9fc89bba6
|
||||
2026-07-06-approval-seam.zh.md: d218f79888957735305db14cd97cc74480297d29
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-approval-seam.md
|
||||
2026-07-06-approval-seam.md: efb4159d736779af28edc1ae6091de4669c92f31
|
||||
2026-07-06-approval-seam.zh.md: 9a4656f30a43473fe90cde9c56d45f48943e5d10
|
||||
@@ -123,7 +123,7 @@ Costs and accepted limits:
|
||||
- **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt.
|
||||
- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two.
|
||||
- **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant.
|
||||
- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred).
|
||||
- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. A `'never'` parent seeds that override into each in-process child's log ([decision](2026-07-25-subagent-policy-inheritance.md)), so the child is told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred).
|
||||
- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair.
|
||||
- **What happens across a hot reload, or when an answerer unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state.
|
||||
- **Where does a client get approval context?** The request carries the exact `callId` and the asker's human-readable `reason`; channel adapters may correlate richer tool-call state without duplicating arguments in the approval seam.
|
||||
|
||||
@@ -123,7 +123,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有
|
||||
- **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。
|
||||
- **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。
|
||||
- **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。
|
||||
- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。
|
||||
- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`'never'` 父级会把该覆盖项预置到每个进程内子 agent 的日志中([决策](2026-07-25-subagent-policy-inheritance.md)),因此子 agent 一开始就会得知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。
|
||||
- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次成功的自动拒绝都会记录审计对。
|
||||
- **热重载或应答者在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。
|
||||
- **客户端从哪里获得审批上下文?** 请求携带精确的 `callId` 和发起方的人类可读 `reason`;通道适配器可自行关联更丰富的工具调用状态,而无需在审批 seam 中重复携带参数。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md
|
||||
2026-07-06-sandbox.md: c3c61ed4539bcbca359f84f3dcc020e0bd41ae79
|
||||
2026-07-06-sandbox.zh.md: 39bf92aa20c697d2ad5f4c0926caeff0a0b60a1d
|
||||
2026-07-06-sandbox.md: 42b78ad8341dd52c4dd146a2207a5ae909d28f1e
|
||||
2026-07-06-sandbox.zh.md: dfa3349e4d74d6f2c4944414c25fe3726d4a9b5a
|
||||
@@ -89,10 +89,10 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode —
|
||||
#### Per-session modes: the session log as the store
|
||||
|
||||
```
|
||||
effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default
|
||||
effective(session) = findLast(the session's knob events)?.value ?? the composition-config default
|
||||
```
|
||||
|
||||
The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation both fall out by construction, and no external config store exists anywhere.
|
||||
The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's log. Restart immunity and multi-session isolation follow from replay, with no external config store. The in-process subagent driver snapshots a parent's explicit override at delegation and seeds a source-tagged event after the child's optional fork prefix, so delegation cannot fall back to a wider default ([decision](2026-07-25-subagent-policy-inheritance.md)).
|
||||
|
||||
**One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages):
|
||||
|
||||
|
||||
@@ -89,10 +89,10 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes
|
||||
#### 按会话模式:会话日志即存储
|
||||
|
||||
```
|
||||
effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default
|
||||
effective(session) = findLast(the session's knob events)?.value ?? the composition-config default
|
||||
```
|
||||
|
||||
默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离都是构造性的自然结果,且不存在任何外部配置存储。
|
||||
默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中。重启免疫与多会话隔离由回放自然保证,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.md))。
|
||||
|
||||
**每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`):
|
||||
|
||||
|
||||
@@ -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-20-dsh-cli-personal-config.md: 9525aa811d792a918f03a52c21bc273e92fb8be7
|
||||
2026-07-20-dsh-cli-personal-config.zh.md: f21d4b1f22b3a3807b6b4155969282343f6048f5
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md
|
||||
2026-07-20-dsh-cli-personal-config.md: cc965438214b68078647596af5a28fd666e7bd95
|
||||
2026-07-20-dsh-cli-personal-config.zh.md: 02f9c578061e1c25044c377c3ec8f80594275ae7
|
||||
@@ -12,7 +12,7 @@ A developer's own preferences — which provider and model the TUI uses, persona
|
||||
|
||||
Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443):
|
||||
|
||||
**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** via the repo's tsx, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry.
|
||||
**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** through Node's native TypeScript transform plus the app-owned tsconfig-paths loader, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry.
|
||||
|
||||
**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim:
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐:
|
||||
|
||||
**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,用仓库的 tsx **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。
|
||||
**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,通过 Node 的原生 TypeScript 转换和应用自身持有的 tsconfig-paths loader **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。
|
||||
|
||||
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
|
||||
|
||||
|
||||
@@ -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-cross-session-references.md
|
||||
2026-07-21-cross-session-references.md: a6c7c3b3e89e032d6d807a25a7661a875f9f7bec
|
||||
2026-07-21-cross-session-references.zh.md: ef78cd7ecee3e2625b446139d7f22e16d0bf1dfa
|
||||
2026-07-21-cross-session-references.md: 61ea30cabb2abf4d4a9b4391891b5987affb91d0
|
||||
2026-07-21-cross-session-references.zh.md: 6fba44103942d29428fd820591815743dfb5d96d
|
||||
@@ -10,7 +10,7 @@ TUI users need to bring relevant work from another conversation into one new mes
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional sourced `UserMessageData` snapshot; core agent packages do not parse session URIs or read another log.
|
||||
`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log.
|
||||
|
||||
`dsh-session:<base64url(JSON.stringify(sessionId))>` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)`; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带来源信息的 `UserMessageData` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。
|
||||
`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。
|
||||
|
||||
`dsh-session:<base64url(JSON.stringify(sessionId))>` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。
|
||||
|
||||
|
||||
@@ -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-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
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Docked web goal bar
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-docked-web-goal-bar.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The web UI had no goal surface at all: the goal stack shipped with model tools, the TUI/ACP adapters, and the `/goal` command, but the browser client exposed none of it — no runtime verbs, no indicator. This change introduces the client goal verbs (runtime session methods over RPC) and the first goal UI together. Placement follows the redesign's premise that goal presence belongs to the composer's context: the goal is a property of the work the user is about to prompt, so its indicator docks directly above the message composer as a rounded-top strip tucked under the composer card's top edge. The mock keeps only a sparkle, a phase word ("Ongoing/Paused/Blocked Goal"), the truncated objective, and edit/clear icon actions, with resume appearing only on a paused goal.
|
||||
|
||||
## Decision
|
||||
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
The strip's background is `--dsw-alias-interactive-bg-hover` rather than the mock's literal `#F5F6F7`: the translucent hover gray resolves to that value over the white light-theme base and lifts the strip off the composer card in dark mode, where a static light token would sink. All colors are `--dsw-*` tokens.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
- 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).
|
||||
- `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 停靠式 Web 目标条
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-docked-web-goal-bar.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、TUI/ACP 适配器和 `/goal` 命令交付,但浏览器客户端完全不接触它——既没有运行时动词,也没有指示器。本变更同时引入客户端目标动词(基于 RPC 的运行时会话方法)和第一个目标 UI。摆放位置遵循重新设计的前提:目标的存在感属于输入框的上下文——目标是用户即将提交的工作的属性,因此它的指示器停靠在消息输入框正上方,呈现为一条圆角顶部的横条,收进输入框卡片顶边之下。设计稿只保留一个闪光图标、一个阶段词("Ongoing/Paused/Blocked Goal")、截断后的目标内容,以及编辑/清除图标操作,恢复按钮仅在目标暂停时出现。
|
||||
|
||||
## 决策
|
||||
|
||||
`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 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。
|
||||
|
||||
`GoalBarActions` 位于 contract 层(`contract/slots.ts`,紧挨它所喂给的 `ConversationInjected.goalActions` 槽位),只携带实际渲染的动词:`onEdit`/`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 结果会被丢弃。
|
||||
|
||||
横条的背景色用 `--dsw-alias-interactive-bg-hover`,而不是设计稿里的字面值 `#F5F6F7`:这个半透明的悬浮灰在浅色主题的白色底上正好解析为该值,而在深色模式下能把横条从输入框卡片上衬托出来,静态的浅色 token 在深色模式下会沉进去。所有颜色都是 `--dsw-*` token。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;放在头部的横条无法停靠进输入框卡片。
|
||||
- **为 `undefined` 渲染 "Loading goal…" 占位**:不予采纳,每次打开会话横条都会闪现再坍缩,对一个不到一秒的状态来说只是界面噪音。
|
||||
- **未设置目标时在横条内提供内联创建入口**:实现评审后不予采纳,创建目标的职责在 `/goal` 命令上,与模型按请求创建目标的模式一致;横条是状态指示器,不是创建入口。
|
||||
- **在 `GoalBarActions` 中携带完整动词集合(`onPause`/`onComplete`)**:作为投机性泛化不予采纳,没有消费方调用它们,接口只携带实际渲染的动词。
|
||||
|
||||
## 后果
|
||||
|
||||
- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及编辑/清除(暂停时另有恢复)——这是浏览器客户端的第一个目标界面。
|
||||
- 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。
|
||||
- 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;暂停/完成对其他界面(`/goal`、模型工具)照常可用。
|
||||
- `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md
|
||||
2026-07-23-web-todo-display.md: 5fe08cc40c1d23ff3a9b8c6d766fea6d3694c30d
|
||||
2026-07-23-web-todo-display.zh.md: c121ffc27e3d0a93707c2c22b2f180023ebae5be
|
||||
2026-07-23-web-todo-display.md: 7223ff9adbf1fa6dca39c9eb4949b6d3861bdd6d
|
||||
2026-07-23-web-todo-display.zh.md: ac773a3c587183aa9a152caff2812686bc79f3c7
|
||||
@@ -14,7 +14,7 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it
|
||||
|
||||
### Side-effect channel, converging with window replay
|
||||
|
||||
`applyEventSideEffects` gains a `todo/write` case (whole list, last write wins). Unlike partial/openCalls, `rebuildDerivedFromWindow` deliberately does NOT reset it: the value is session-level — taken from the tail history page's full-log projection — and an arbitrary window may not contain the latest write, so an older-page prepend keeps it and only an in-window or live write overwrites it. Every `installWindow` caller is a tail request (`doOpen`, its gap re-pull, `repairGap`; `loadOlder` prepends without it), which the host answers with the projection or omits it only when the full log holds no `todo/write` — so an absent field is the authoritative empty list and is assigned as such. That distinction matters on rollback: a live write whose host crashed before persisting leaves the log empty, and preserving the prior value instead would strand the rolled-back plan on screen indefinitely. `ConversationSnapshot.todos` is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing.
|
||||
`applyEventSideEffects` gains a `todo/write` case (whole list, last write wins) and clears on `turn/start` ([turn-scoped plan lifetime](2026-07-28-todo-plan-clears-on-next-turn.md)). `rebuildDerivedFromWindow` sweeps the window from an empty plan and restores the tail-page seed only when the window never determined the plan (no `todo/write` and no `turn/start`); otherwise the in-window write/`turn/start` fold wins. Every `installWindow` caller is a tail request (`doOpen`, its gap re-pull, `repairGap`; `loadOlder` prepends without reseeding), which the host answers with the projection or omits it when no plan stands — so an absent field is the authoritative empty list and is assigned as such. That distinction matters on rollback: a live write whose host crashed before persisting leaves the log empty, and preserving the prior value instead would strand the rolled-back plan on screen indefinitely. `ConversationSnapshot.todos` is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing.
|
||||
|
||||
### TodoPanel: the durable list as a persistent strip
|
||||
|
||||
@@ -33,4 +33,4 @@ The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`
|
||||
|
||||
## Consequences
|
||||
|
||||
Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 65) plus the assembled keyless snapshot (`apps/web/tests/todo-display.snapshot.ts`) pin the full chain (row summary and state, dock panel content, collapse round-trip) over the built client graph. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The TUI panel is untouched (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log latest `todo/write`, computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan even when the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, and resets to empty when a tail response carries no projection.
|
||||
Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 65) plus the assembled keyless snapshot (`apps/web/tests/todo-display.snapshot.ts`) pin the full chain (row summary and state, dock panel content, collapse round-trip) over the built client graph. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The TUI panel shares the same turn-scoped lifetime (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection.
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
### 副作用通道,与窗口回放收敛
|
||||
|
||||
`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写)。与 partial/openCalls 不同,`rebuildDerivedFromWindow` 刻意不重置它:该值是会话级的——取自尾页 history 携带的全量 log 投影——而任意窗口未必包含最近一次写入,因此往前翻页保留它,只有窗口内或实时的写入才会覆盖。`installWindow` 的每个调用方都是尾页请求(`doOpen`、其补洞重拉、`repairGap`;`loadOlder` 只往前拼接、不走它),而 host 对尾页请求要么带上投影、要么仅在全量 log 没有任何 `todo/write` 时省略——因此字段缺失就是权威的空列表,直接照此赋值。这个区分在回滚场景上要紧:实时写入若在 host 持久化前崩溃,log 里就是空的,此时保留旧值会让已回滚的计划永远留在屏幕上。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。
|
||||
`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写),并在 `turn/start` 清空([按 turn 界定的计划生命周期](2026-07-28-todo-plan-clears-on-next-turn.md))。`rebuildDerivedFromWindow` 从空计划扫过窗口,仅当窗口从未判定计划(无 `todo/write` 且无 `turn/start`)时恢复尾页种子;否则以窗口内写入/`turn/start` 折叠为准。`installWindow` 的每个调用方都是尾页请求(`doOpen`、其补洞重拉、`repairGap`;`loadOlder` 只往前拼接、不再播种),而 host 对尾页请求要么带上投影、要么在无站立计划时省略——因此字段缺失就是权威的空列表,直接照此赋值。这个区分在回滚场景上要紧:实时写入若在 host 持久化前崩溃,log 里就是空的,此时保留旧值会让已回滚的计划永远留在屏幕上。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。
|
||||
|
||||
### TodoPanel:长驻列表作为一条常驻横条
|
||||
|
||||
@@ -33,4 +33,4 @@ Status: implemented
|
||||
|
||||
## Consequences
|
||||
|
||||
回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 65 轮的 fixture(测试前置数据)加 assembled keyless snapshot(`apps/web/tests/todo-display.snapshot.ts`)在构建产物客户端全图上钉住整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板未受改动(自动化专用的 ACP 桥接刻意不做 todo 呈现);Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。冷加载重建正是靠这个字段由 host 兜底:history 尾页附带 `todos`——全量 log 上最新一次 `todo/write` 的投影,独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时即使最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,而尾页响应不带投影时复位为空。
|
||||
回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 65 轮的 fixture(测试前置数据)加 assembled keyless snapshot(`apps/web/tests/todo-display.snapshot.ts`)在构建产物客户端全图上钉住整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板共用同一按 turn 界定的生命周期(自动化专用的 ACP 桥接刻意不做 todo 呈现);Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。冷加载重建正是靠这个字段由 host 兜底:history 尾页附带 `todos`——全量 log 上的站立计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍站立且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。
|
||||
@@ -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-25-subagent-policy-inheritance.md
|
||||
2026-07-25-subagent-policy-inheritance.md: aeff83795eedead9c75de6bbb74c1da1945092ca
|
||||
2026-07-25-subagent-policy-inheritance.zh.md: 7a09fd972f53b46655df93ddf9625455f2077460
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox and approval overrides
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-25-subagent-policy-inheritance.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`, or turn a parent's unattended `'never'` approval stance back into prompting behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
The shared in-process driver snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants.
|
||||
|
||||
Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` at the fork-prefix length, so the inherited facts follow fork history, reach telemetry when the child is announced, and leave `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism.
|
||||
|
||||
Ordinary session appends validate the inherited events before publication, and persistence captures the complete unpublished log when the session is announced. Any materialized child log therefore stores the inherited events with its first batch; there is no second policy store, schema field, or query index. The `source: 'delegation'` marker lets approval narration distinguish inheritance from a child-side user switch.
|
||||
|
||||
### What a blocked child experiences
|
||||
|
||||
A confined child gets the ordinary denial marker. No answerer currently owns an in-process child, so an escalation request fails closed and the child reports upward; a controller-owned parent may widen its own session and delegate again. An inherited `'never'` policy tells the child not to request escalation in its first system prompt.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Generic `SessionHeader` policy fields** — rejected: they duplicate an event-sourced fact in metadata and require propagation through core session types, persistence backends, query indexes, collision identity, and every policy consumer. Unpublished setup events have the required ordering and reuse the existing durable store.
|
||||
- **Combining new policy facts with constructor history** — rejected: `Session.firstLiveSeq` classifies the complete constructor seed as replayed history, so telemetry would skip child-only facts. Unpublished setup keeps history and new facts on their existing sides of that boundary without another session option.
|
||||
- **A first-prompt listener** — rejected: it introduces listener ordering and a later timing boundary even though the creation transaction already permits log appends before publication.
|
||||
- **Copying deployment defaults** — rejected: defaults remain operator-owned and may change; an unswitched parent stamps nothing, so its child follows the current deployment.
|
||||
- **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening.
|
||||
- **Forcing `'never'` or routing asks to the root controller** — rejected as inheritance behavior. A forced value forecloses a future child answerer; parent routing needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal.
|
||||
- The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed.
|
||||
- Each delegation adds at most two log-only events. `dsh-subagent-inprocess` has optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱与审批覆盖项下启动
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-25-subagent-policy-inheritance.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent(智能体)过去会回退到部署默认值,fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级,或让父级无人值守的 `'never'` 审批立场重新变成会发起提示的行为。
|
||||
|
||||
## 决策
|
||||
|
||||
共享的进程内驱动器在第一次 await 之前对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。
|
||||
|
||||
每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已将 `Session.firstLiveSeq` 固定为 fork 前缀的长度,因此继承事实会排在 fork 历史之后,在子 agent 公布时进入遥测,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。
|
||||
|
||||
普通的会话追加会在发布前校验继承事件,持久化层则在会话公布时捕获完整的未发布日志。因此,任何已物化的子 agent 日志都会在首批数据中存下继承事件;不存在第二套策略存储、schema 字段或查询索引。`source: 'delegation'` 标记让审批叙述能够区分继承与子 agent 侧的用户切换。
|
||||
|
||||
### 被拦住的子 agent 会经历什么
|
||||
|
||||
受限子 agent 会得到普通拒绝标记。目前没有应答器认领进程内子 agent,因此升级请求会失败关闭,由子 agent 向上汇报;由控制器持有的父 agent 可以放宽自己的会话后重新委派。继承的 `'never'` 策略会在第一份系统提示词中告知子 agent 不要请求升级。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **通用的 `SessionHeader` 策略字段**:不予采纳。它们会在元数据中复制一项事件溯源事实,并要求贯穿核心会话类型、持久化后端、查询索引、碰撞标识与每个策略消费方进行传播。未发布设置阶段的事件具备所需顺序,并复用现有持久化存储。
|
||||
- **将新策略事实与构造历史合并**:不予采纳。`Session.firstLiveSeq` 会把完整的构造种子归类为回放历史,因此遥测会跳过仅属于子 agent 的事实。未发布设置让历史与新事实留在该边界各自原有的一侧,无需再增加会话选项。
|
||||
- **首个提示词监听器**:不予采纳。尽管创建事务已经允许在发布前追加日志,它仍会引入监听器顺序与更晚的时序边界。
|
||||
- **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会盖章写入任何内容,因此其子 agent 跟随当前部署。
|
||||
- **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。
|
||||
- **强制使用 `'never'` 或把 ask 路由到根控制器**:不作为继承行为采纳。强制值会排除未来的子 agent 应答器;父级路由需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。
|
||||
|
||||
## 后果
|
||||
|
||||
- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。
|
||||
- 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。
|
||||
- 每次委派最多增加两条仅日志事件。`dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。
|
||||
@@ -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-sdk-max-output-tokens.md
|
||||
2026-07-28-sdk-max-output-tokens.md: 5db48f21892d56addea7b72f73319f9dbfd1e71f
|
||||
2026-07-28-sdk-max-output-tokens.zh.md: 38b172718716d100726188163ff22be9ea0a7325
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: SDK max output tokens
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-sdk-max-output-tokens.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Python and TypeScript SDKs could select a provider and model but could not bound conversation-model output. The runtime therefore omitted `GenerateOptions.maxTokens`, leaving provider defaults in control even when an evaluation host required a fixed output budget. `compact-basic.maxTokens` could not fill this role because it limits only compaction-summary calls.
|
||||
|
||||
## Decision
|
||||
|
||||
The high-level SDKs expose one optional process-wide output cap: Python names it `max_tokens`, TypeScript names it `maxTokens`, and the shared `initialize` wire payload carries `maxTokens`. The JSON-RPC server rejects values that are not positive safe integers and stores the accepted cap with its provider/model route.
|
||||
|
||||
Each SDK-created root Agent receives the cap through `AgentOptions.maxTokens`. Agent Loop places that value in the initial `LlmCallConfig`, logs it in the request header, and reconstructs every dispatched conversation request from that durable header. Omitting the option leaves `maxTokens` absent so the selected provider retains its default.
|
||||
|
||||
In-process subagents inherit the parent's provider, model, and output cap. An explicit `SubagentStartRequest.agentOptions.maxTokens`, including one configured by `dsh-tool-subagent`, overrides the inherited value for that child and its descendants. Out-of-process providers own the configuration of their separate runtime; `subagent-dsh-sdk` therefore exposes its own optional `maxTokens` and forwards it through that child runtime's SDK handshake.
|
||||
|
||||
Compaction, session-title generation, web search, and other auxiliary calls keep their independently owned output limits. `maxTokensAsSuccess` remains outcome mapping only: it does not set or alter the cap.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Set an adapter environment variable.** This would be DeepSeek-adapter-specific, invisible in the session request header, ineffective for intercepted or alternate adapters, and easy to confuse with a provider default. The cap belongs in provider-neutral request configuration.
|
||||
|
||||
**Add `maxTokens` to every `session/prompt`.** Per-turn mutation would enlarge the wire and introduce request-config transitions that callers do not need for the current evaluation use case. A runtime initialization option gives every session in one SDK process the same reproducible budget.
|
||||
|
||||
**Reuse `compact-basic.maxTokens`.** The compaction value controls summary generation, not ordinary conversation requests. Sharing it would couple two different token budgets and make tuning one silently change the other.
|
||||
|
||||
## Consequences
|
||||
|
||||
SDK callers can bound model output without editing Cordis composition, and direct Agent creation uses the same validated `AgentOptions` contract. The cap is visible in durable request headers and reaches provider adapters as `GenerateOptions.maxTokens`; DeepSeek serialization maps it to `max_tokens`.
|
||||
|
||||
One SDK runtime has one default cap. A caller needing different caps runs separate runtime instances or explicitly overrides an in-process child through its agent options. Reaching the cap still produces the existing `max-tokens` stop reason, whose `ok` or `error` mapping remains deployment policy.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: SDK 最大输出 token
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-sdk-max-output-tokens.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Python 与 TypeScript SDK 可以选择提供方和模型,却无法限制对话模型输出。即使评测宿主要求固定输出预算,运行时仍会省略 `GenerateOptions.maxTokens`,由提供方默认值控制。`compact-basic.maxTokens` 只限制压缩摘要调用,不能承担这一职责。
|
||||
|
||||
## Decision
|
||||
|
||||
高层 SDK 公开一个可选的进程级输出上限:Python 命名为 `max_tokens`,TypeScript 命名为 `maxTokens`,共享的 `initialize` 线载荷使用 `maxTokens`。JSON-RPC 服务端拒绝非正安全整数,并将通过校验的上限与提供方/模型路由一同保存。
|
||||
|
||||
每个由 SDK 创建的根 Agent 都通过 `AgentOptions.maxTokens` 获得该上限。Agent Loop 将它放入初始 `LlmCallConfig`、记录到请求 header,并从该持久化 header 重建每次分派的对话请求。省略该选项时,`maxTokens` 保持缺失,由所选提供方保留默认值。
|
||||
|
||||
进程内 subagent 继承父级的提供方、模型和输出上限。显式的 `SubagentStartRequest.agentOptions.maxTokens`(包括通过 `dsh-tool-subagent` 配置的值)会覆盖该子级及其后代的继承值。进程外提供方自行持有其独立运行时的配置;因此 `subagent-dsh-sdk` 公开独立的可选 `maxTokens`,并通过该子运行时自己的 SDK 握手传入。
|
||||
|
||||
压缩、会话标题生成、网页搜索和其他辅助调用继续使用各自持有的独立输出上限。`maxTokensAsSuccess` 仍然只负责结果映射,不会设置或改变上限。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**设置适配器环境变量。** 这种方式仅适用于 DeepSeek 适配器,不会出现在会话请求 header 中,对被拦截请求或其他适配器无效,也容易与提供方默认值混淆。该上限属于提供方无关的请求配置。
|
||||
|
||||
**在每个 `session/prompt` 上增加 `maxTokens`。** 按轮次修改会扩大线协议,并引入当前评测用例不需要的请求配置转换。运行时初始化选项可让一个 SDK 进程中的每个会话拥有相同、可重现的预算。
|
||||
|
||||
**复用 `compact-basic.maxTokens`。** 压缩值控制摘要生成,而非普通对话请求。共用会耦合两类不同 token 预算,调整一方时会静默改变另一方。
|
||||
|
||||
## Consequences
|
||||
|
||||
SDK 调用方无需修改 Cordis 组合即可限制模型输出,直接创建 Agent 也使用同一套经过校验的 `AgentOptions` 契约。该上限在持久化请求 header 中可见,并以 `GenerateOptions.maxTokens` 到达提供方适配器;DeepSeek 序列化会将其映射为 `max_tokens`。
|
||||
|
||||
一个 SDK 运行时只有一个默认上限。需要不同上限的调用方应运行独立的 runtime 实例,或通过 agent options 显式覆盖某个进程内子级。达到上限时仍产生现有的 `max-tokens` 停止原因;将其映射为 `ok` 还是 `error` 仍由部署策略决定。
|
||||
@@ -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-todo-plan-clears-on-next-turn.md
|
||||
2026-07-28-todo-plan-clears-on-next-turn.md: 8a2808f5023a0800fbbeb65c5e28b3495e051dbc
|
||||
2026-07-28-todo-plan-clears-on-next-turn.zh.md: 6ea6b2b4fafe4b2f695ad2d54561f164747e5694
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Todo plan strip clears on the next turn
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-todo-plan-clears-on-next-turn.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`todo_write` stores whole-list snapshots on the session log, and interactive hosts render the latest list as a plan strip (web TodoPanel via the `todos` projection, TUI Plan panel). After a turn finished, that strip stayed on screen into the next user turn — a completed or abandoned checklist from the previous task. Readers treat the strip as "what this turn is doing," so a stale list across the turn boundary is the wrong product lifetime. The [web todo display](2026-07-23-web-todo-display.md) and [`todo_write` tool](2026-06-29-todo-write-tool.md) notes still own event-sourcing and the two render surfaces; they described the standing plan as lasting for the whole session until the next write.
|
||||
|
||||
## Decision
|
||||
|
||||
The standing plan is the latest `todo/write` that is not followed by a later `turn/start`. `turn/end` keeps the list visible so the finished checklist remains while the user reads the answer; the next `turn/start` clears it until the model writes again.
|
||||
|
||||
### Host projection (web)
|
||||
|
||||
`dsh-tool-todo`'s `todos` projection unit folds the rule: `apply` takes the whole list from each `todo/write` and returns `null` on each `turn/start` (`stateVersion` 2). Carriers (`dsh-host-apiproxy`) serve that value on the history tail `projections` block and push `session/projection` frames; the web dock reads it through `useProjection('todos')`. The keyless fixture mirrors the same fold for assembled snapshots.
|
||||
|
||||
### TUI live path
|
||||
|
||||
The TUI `renderEvent` switch still clears its local plan panel on `turn/start` and replaces it on `todo/write` (TUI is not yet a projection carrier). The rebuild path resets the panel before replaying the log so cold resume converges on the same rule.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Clear on `turn/end`** — hides the checklist while the user is still reading the just-finished answer; the strip's job at that moment is the completed plan, not an empty dock.
|
||||
- **Clear only when every item is `completed`** — leaves abandoned or partial plans across turns; the strip would still show another task's work.
|
||||
- **Append an empty `todo/write` on turn start** — mutates the log for a UI lifetime rule and invents a write the model never authored.
|
||||
|
||||
## Consequences
|
||||
|
||||
The host projection and the TUI panel share one lifetime rule; reopening a session restores a plan only when no later turn has started. Partial supersession of the session-long standing-plan wording in [web todo display](2026-07-23-web-todo-display.md) and [`todo_write` tool](2026-06-29-todo-write-tool.md): event-sourcing, last-write-wins replacement, and the two render surfaces stay there; this note owns turn-boundary clearance. Coverage: tool-todo projection specs for turn/start clear + turn/end keep, fixture push-frame clearance for the assembled web snapshot, plus the TUI snapshot that starts the next turn and pins the strip gone.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 下一轮开始时清空 todo 计划条
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-todo-plan-clears-on-next-turn.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`todo_write` 在会话日志中存储整表快照,交互式宿主把最新列表渲染为计划条(web TodoPanel 经 `todos` 投影,TUI Plan 面板)。一轮结束后,该条仍留在下一用户轮次的屏幕上——上一任务已完成或已放弃的清单。读者把计划条理解为「本轮正在做什么」,因此跨轮次的陈旧列表是错误的产品生命周期。[web todo 展示](2026-07-23-web-todo-display.md)与 [`todo_write` 工具](2026-06-29-todo-write-tool.md)笔记仍拥有事件溯源与两个渲染面;它们把站立计划描述为持续整段会话直至下一次写入。
|
||||
|
||||
## 决策
|
||||
|
||||
站立计划是其后没有更晚 `turn/start` 的最近一次 `todo/write`。`turn/end` 保留列表可见,以便用户阅读回答时仍能看到刚完成的清单;下一次 `turn/start` 将其清空,直至模型再次写入。
|
||||
|
||||
### Host 投影(web)
|
||||
|
||||
`dsh-tool-todo` 的 `todos` 投影单元折叠该规则:`apply` 从每个 `todo/write` 取整表,并在每个 `turn/start` 返回 `null`(`stateVersion` 2)。载体(`dsh-host-apiproxy`)在历史尾页的 `projections` 块与 `session/projection` 推送帧上供给该值;web dock 经 `useProjection('todos')` 读取。无密钥 fixture(测试前置数据)镜像同一折叠,供组装后的 snapshot 使用。
|
||||
|
||||
### TUI 实时路径
|
||||
|
||||
TUI 的 `renderEvent` 分支仍在 `turn/start` 清空本地计划面板、在 `todo/write` 替换之(TUI 尚非投影载体)。重建路径在回放日志前重置面板,使冷恢复收敛到同一规则。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **在 `turn/end` 清空**——用户仍在阅读刚完成的回答时就隐藏清单;此时计划条的职责是已完成计划,而非空 dock。
|
||||
- **仅在全部项为 `completed` 时清空**——会让放弃或部分完成的计划跨轮残留;计划条仍会显示另一任务的工作。
|
||||
- **在 turn start 追加空的 `todo/write`**——为 UI 生命周期规则改写日志,并捏造模型从未写出的写入。
|
||||
|
||||
## 后果
|
||||
|
||||
Host 投影与 TUI 面板共用同一生命周期规则;重新打开会话仅在其后没有更晚轮次开始时恢复计划。部分取代 [web todo 展示](2026-07-23-web-todo-display.md)与 [`todo_write` 工具](2026-06-29-todo-write-tool.md)中「会话级站立计划」的表述:事件溯源、last-write-wins 替换与两个渲染面仍归那些笔记;本笔记拥有轮次边界清空。覆盖:tool-todo 投影对 turn/start 清空与 turn/end 保留的规格测试、供组装 web snapshot 的 fixture 推送帧清空,以及启动下一轮并钉住计划条消失的 TUI snapshot。
|
||||
@@ -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-tool-call-file-open-in-os.md
|
||||
2026-07-28-tool-call-file-open-in-os.md: a2c9b52507d32c2d851f811f0ecdd878a60b1e1c
|
||||
2026-07-28-tool-call-file-open-in-os.zh.md: efb4c39503d9de71a9d773bdae7fac4fb2b08ee3
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: Tool-call file open in OS
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-tool-call-file-open-in-os.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Chat tool rows treated the whole summary line as a click target that opened the right-hand details panel, with a hover background on the row. For filesystem tools the useful action is opening the mentioned file in the operating system's default application, not inspecting the raw tool payload in a sidebar.
|
||||
|
||||
## Decision
|
||||
|
||||
File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as hover-underline links with a pointer cursor. Clicking the path calls `host.openPath` through `WorkspacesService.openPath`, resolving relative paths against the session cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them.
|
||||
|
||||
`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, `xdg-open` on Linux. The opener is injectable for tests. URL-only read args (`web_fetch`) are not file links.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Keep row-click details and add a separate file affordance — rejected; the product ask replaces the row gesture with the file link.
|
||||
- Open files inside an in-app preview — rejected; the ask is the OS default application.
|
||||
- Reuse `host.pickDirectory`'s timeout exemption — unnecessary; path open hand-off completes quickly under the normal unary deadline.
|
||||
|
||||
## Consequences
|
||||
|
||||
Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`.
|
||||
|
||||
## Risks
|
||||
|
||||
- Linux hosts without `xdg-open` fail the RPC; the chat row stays silent while the host returns an internal error.
|
||||
- Relative paths without a session cwd are forwarded verbatim and may fail on the host.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: 在工具调用中用系统应用打开文件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-tool-call-file-open-in-os.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
聊天工具行把整行摘要当作点击目标,点击后打开右侧 details 面板,并带有整行悬停背景。对文件系统工具而言,有用的动作是用操作系统默认应用打开所涉文件,而不是在侧栏里查看原始工具载荷。
|
||||
|
||||
## Decision
|
||||
|
||||
文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为悬停下划线链接并使用 pointer 光标。点击路径会经 `WorkspacesService.openPath` 调用 `host.openPath`,相对路径相对会话 cwd 解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。
|
||||
|
||||
`host.openPath` 是特权一元 RPC,仅接受来自回环、同源浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,Linux 为 `xdg-open`。打开器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- 保留整行点击打开 details,另加文件入口 — 否决;产品要求用文件链接替换整行手势。
|
||||
- 在应用内预览文件 — 否决;要求是操作系统默认应用。
|
||||
- 复用 `host.pickDirectory` 的超时豁免 — 不必要;打开路径的交接在常规一元截止时间内即可完成。
|
||||
|
||||
## Consequences
|
||||
|
||||
点击工具行中的文件路径会在宿主上打开该路径。非文件工具行是惰性摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。
|
||||
|
||||
## Risks
|
||||
|
||||
- 没有 `xdg-open` 的 Linux 宿主会使 RPC 失败;聊天行保持静默,宿主返回 internal 错误。
|
||||
- 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md
|
||||
2026-06-16-pnpm-over-yarn.md: 918da1d056dfec73f78d849e08ad06d397e654c0
|
||||
2026-06-16-pnpm-over-yarn.zh.md: 4fbb357f689b4a960d0662cbefa5bec30f79bcc1
|
||||
2026-06-16-pnpm-over-yarn.md: 30b34c65fdea94b20dec4d627a0fca40de760fd1
|
||||
2026-06-16-pnpm-over-yarn.zh.md: eb13890b7e7051301874b9273966771328b065a3
|
||||
@@ -8,7 +8,7 @@ English | [中文](2026-06-16-pnpm-over-yarn.zh.md)
|
||||
|
||||
The repo shipped on **Yarn 4** with the `node-modules` linker — a deliberately conservative choice that behaves like npm's flat layout while giving us Yarn's workspaces and `yarn constraints`. It worked. But Yarn 4's Plug'n'Play heritage makes the `node-modules` linker the off-the-beaten-path mode, and the broader JS ecosystem — tooling defaults, CI actions, Corepack examples, contributor familiarity — increasingly centers on pnpm. For a repo that is built primarily by agents and read by occasional human contributors, "the package manager most tools and people expect" has real value: fewer surprises, better-trodden failure paths, more copy-pasteable answers.
|
||||
|
||||
The switching cost is at its lowest right now. Nothing publishes from this repo yet (every package is `private: true`); dev/test/demo all run **unbuilt** via tsx, so the package manager only has to (a) resolve and link `node_modules`, (b) run the workspace scripts, and (c) enforce the workspace constraints. The one Yarn-specific asset is `yarn.config.cjs` (the `@yarnpkg/types` constraints engine), which is small and mechanical to re-express. This mirrors the reasoning in [the tsdown decision](../../archived/process/2026-06-11-tsdown-over-dumble.md): swap a load-bearing tool for the healthier-ecosystem option while the blast radius is still small.
|
||||
The switching cost is at its lowest right now. Nothing publishes from this repo yet (every package is `private: true`); development, tests, and source-mode demos run through their declared TypeScript launchers, while artifact checks build explicitly. The package manager therefore only has to (a) resolve and link `node_modules`, (b) run the workspace scripts, and (c) enforce the workspace constraints. The one Yarn-specific asset is `yarn.config.cjs` (the `@yarnpkg/types` constraints engine), which is small and mechanical to re-express. This mirrors the reasoning in [the tsdown decision](../../archived/process/2026-06-11-tsdown-over-dumble.md): swap a load-bearing tool for the healthier-ecosystem option while the blast radius is still small.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Status: implemented
|
||||
|
||||
本仓库最初使用 **Yarn 4** 搭配 `node-modules` 链接器启动。这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时享有 Yarn 的 workspaces 和 `yarn constraints`。它能正常工作。但 Yarn 4 源自 Plug'n'Play 的血统,使得 `node-modules` 链接器成为非主流模式;而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者的熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent(智能体)构建、偶尔有人类贡献者阅读的仓库而言,「大多数工具和人所期望的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的解答。
|
||||
|
||||
切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发/测试/演示全部通过 tsx **未构建**运行,因此包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。
|
||||
切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发流程、测试和源码模式 demo 都通过各自声明的 TypeScript 启动器运行,产物检查则会显式构建。因此,包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。
|
||||
|
||||
## 决策
|
||||
|
||||
|
||||
@@ -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-17-ts-build-config.md: 5bdfc5e170f12cd95a68f443ab8d02b16db554f3
|
||||
2026-06-17-ts-build-config.zh.md: 9f74fb6be9c8e00a070e27a609edf8421a2b5ca6
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-17-ts-build-config.md
|
||||
2026-06-17-ts-build-config.md: 20dd3d8d0a11e01397c36388903cd112a170f0bf
|
||||
2026-06-17-ts-build-config.zh.md: 196ed3f2921a4be6f65cd56fc496993b7121f484
|
||||
@@ -62,7 +62,7 @@ pnpm run clean:
|
||||
tsx scripts/clean.ts
|
||||
```
|
||||
|
||||
`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step.
|
||||
The source-mode demos run through their declared TypeScript launchers and the root paths map. The `dsh` TUI chain uses Node's native transform plus its app-owned paths loader, the Web demo builds its required artifacts before entering that same CLI source chain, and the other source demos continue to use tsx.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -75,7 +75,7 @@ tsx scripts/clean.ts
|
||||
|
||||
Build responsibilities are clearer:
|
||||
|
||||
- Each module under `packages/<group>/<pkg>` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`.
|
||||
- Each module under `packages/<group>/<pkg>` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`.
|
||||
- The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`.
|
||||
- `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output.
|
||||
- `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files.
|
||||
|
||||
@@ -62,7 +62,7 @@ pnpm run clean:
|
||||
tsx scripts/clean.ts
|
||||
```
|
||||
|
||||
`pnpm run demo:*` 仍通过 tsx 和根路径直接运行 `src`,无需编译步骤。
|
||||
源码模式 demo 通过各自声明的 TypeScript 启动器和根路径映射运行。`dsh` TUI 链使用 Node 原生转换及应用自有的路径 loader,Web demo 在进入同一条 CLI 源码链路前先构建所需产物,其他源码 demo 继续使用 tsx。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -75,7 +75,7 @@ tsx scripts/clean.ts
|
||||
|
||||
构建职责更加清晰:
|
||||
|
||||
- `packages/<group>/<pkg>` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `tsx` 和 `vitest`)。
|
||||
- `packages/<group>/<pkg>` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。
|
||||
- `build` 命令驱动根 solution 图。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责 `lib/index.*`。
|
||||
- `lib/types/*.d.ts` 和 `.d.ts.map` 是发布用的声明输出。
|
||||
- `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.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/simplification/2026-07-24-agent-loop-observable-state-machine.md
|
||||
2026-07-24-agent-loop-observable-state-machine.md: 54730de8aa73342b609d423dc478edb076d7844b
|
||||
2026-07-24-agent-loop-observable-state-machine.zh.md: 206ac701472f14823300df0c812f2cc818f852f5
|
||||
2026-07-24-agent-loop-observable-state-machine.md: a25657c6a41e2c0989db620046f44ea3254be151
|
||||
2026-07-24-agent-loop-observable-state-machine.zh.md: 058d89d3cb9e3d30963f95fda1510ef3c5bf281e
|
||||
+2
-2
@@ -18,7 +18,7 @@ The public contract exposes four orthogonal state dimensions:
|
||||
|
||||
- Registration lifetime is the `agent/created` to `agent/disposed` interval. Disposal is the terminal registry edge, not an `AgentStatus`.
|
||||
- Whole-agent activity is `AgentStatus = 'idle' | 'running'`. Consecutive turns may share one `running` interval.
|
||||
- A FIFO-backed message progresses from `agent/inbox/enqueue` to exactly one `agent/inbox/dequeue` or `agent/inbox/discard`, correlated by `AgentMessageId`. The inbox events describe acceptance, claim, and removal rather than turn completion.
|
||||
- A FIFO-backed message progresses from `agent/inbox/enqueue` to exactly one `agent/inbox/dequeue` or `agent/inbox/discard`. Enqueue and dequeue correlate an occurrence by `MessageId` plus its queued-or-steering placement; same-placement repeats retire in FIFO order. The inbox events describe acceptance, claim, and removal rather than turn completion.
|
||||
- A claimed turn passes through prompt admission and zero or more request steps. An automatic retry closes the failed turn and immediately opens another; `agent/settled` reports only the terminal turn in that chain and remains distinct from the whole-agent transition to `status === 'idle'`.
|
||||
|
||||
The loop keeps five machine extension events. `agent/prompt-submit` admits, rewrites, or blocks a claimed prompt. `agent/step` is the single awaited between-steps checkpoint and runs before every request is derived. `agent/request` is the waterfall for the frozen call configuration; the configuration comes only from `await next()`, not from a duplicate positional argument. `agent/request-error` serializes ownership of awaited model-request recovery. `agent/turn-stopping` runs when the turn otherwise has no work left; a listener that needs another step records real steering with `agent.steer()`, and the loop decides from that data after all listeners settle.
|
||||
@@ -47,7 +47,7 @@ Plugins no longer rewrite every phase of the loop. There is no request-only mess
|
||||
|
||||
Continuation plugins publish durable steering rather than returning an unlogged reason. Recovery plugins act after the failed step and return an explicit retry action. This makes every attempt a complete turn while keeping asynchronous repair and policy ownership at one narrow waterfall boundary.
|
||||
|
||||
The inbox lifecycle complements, rather than replaces, the durable session log. `AgentMessageId` correlates acceptance with claim or discard; turn and step numbers, messages, tool activity, and terminal reasons remain session facts.
|
||||
The inbox lifecycle complements, rather than replaces, the durable session log. `MessageId` correlates acceptance with claim or discard; turn and step numbers, messages, tool activity, and terminal reasons remain session facts.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
+3
-3
@@ -18,10 +18,10 @@ agent 生命周期、agent 整体活动状态、收件箱条目的进度以及
|
||||
|
||||
- 注册生命周期是从 `agent/created` 到 `agent/disposed` 的区间。dispose(资源释放)是注册表的终止边界,而不是一种 `AgentStatus`。
|
||||
- agent 整体活动状态为 `AgentStatus = 'idle' | 'running'`。连续多个轮次可以共用同一个 `running` 区间。
|
||||
- 由 FIFO 支撑的消息从 `agent/inbox/enqueue` 开始,最终必然进入 `agent/inbox/dequeue` 或 `agent/inbox/discard` 二者之一,并通过 `AgentMessageId` 关联。收件箱事件描述接受、领取和移除,而不是轮次完成。
|
||||
- 由 FIFO 支撑的消息从 `agent/inbox/enqueue` 开始,最终必然进入 `agent/inbox/dequeue` 或 `agent/inbox/discard` 二者之一。enqueue 与 dequeue 通过 `MessageId` 加 queued 或 steering(中途引导)放置方式关联一次消息出现;放置方式相同的重复项按 FIFO 顺序结算。收件箱事件描述接受、领取和移除,而不是轮次完成。
|
||||
- 已领取的轮次经过提示词准入和零个或多个请求步骤。自动重试会关闭失败轮次并立即开启另一个轮次;`agent/settled` 只报告该重试链的终态轮次,且仍不同于 agent 整体转换到 `status === 'idle'`。
|
||||
|
||||
循环保留五个状态机扩展事件。`agent/prompt-submit` 对已领取的提示词执行准入、改写或阻断。`agent/step` 是步骤之间唯一需要等待的检查点,在每次派生请求前运行。`agent/request` 是冻结调用配置所用的 waterfall;配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/turn-stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering(中途引导),循环在所有监听器完成后根据这份数据作出决定。
|
||||
循环保留五个状态机扩展事件。`agent/prompt-submit` 对已领取的提示词执行准入、改写或阻断。`agent/step` 是步骤之间唯一需要等待的检查点,在每次派生请求前运行。`agent/request` 是冻结调用配置所用的 waterfall;配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/turn-stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering,循环在所有监听器完成后根据这份数据作出决定。
|
||||
|
||||
是否继续和终止执行由数据表达,不再由返回的控制枚举表达。工具调用和已接受的 steering 要求再执行一个步骤。携带 `concludesTurn` 的工具结果会在其所属步骤终止工具循环。循环不再暴露通用的 `ContinuationDecision` 或终止停止返回通道。
|
||||
|
||||
@@ -47,7 +47,7 @@ agent 生命周期、agent 整体活动状态、收件箱条目的进度以及
|
||||
|
||||
负责继续执行的插件发布可持久化的 steering,而不是返回未记录到日志中的原因。恢复插件在失败步骤结束后处理错误,并返回显式重试动作。这样,每次尝试都会成为完整轮次,同时异步修复和策略归属集中在一个狭窄的 waterfall 边界。
|
||||
|
||||
收件箱生命周期用于补充持久会话日志,而非取代它。`AgentMessageId` 将接受操作与领取或丢弃操作关联起来;轮次编号与步骤编号、消息、工具活动和终止原因仍属于会话事实。
|
||||
收件箱生命周期用于补充持久会话日志,而非取代它。`MessageId` 将接受操作与领取或丢弃操作关联起来;轮次编号与步骤编号、消息、工具活动和终止原因仍属于会话事实。
|
||||
|
||||
## 相关内容
|
||||
|
||||
|
||||
@@ -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-19-acp-snapshot-tests.md: 430441e633af1e487f19272900360a8ed2f595c9
|
||||
2026-06-19-acp-snapshot-tests.zh.md: 243e431567b45d69e1fe16dda5d07ec058403b7c
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
|
||||
2026-06-19-acp-snapshot-tests.md: e118ada58230fe31fbb2a6bffb83e5612757ab1f
|
||||
2026-06-19-acp-snapshot-tests.zh.md: 5c239d2c2589708050cd6199231ec5047f225107
|
||||
@@ -53,11 +53,11 @@ Replay uses a `cordis.snapshot.yml` overlay that replaces the real adapter with
|
||||
A snapshot run asserts **two** normalized surfaces, because the harness's external surfaces are distinct:
|
||||
|
||||
1. The **stdout transcript** — the framed ACP JSON-RPC responses and committed-message updates an automation client receives. It catches regressions in the transport contract and is compared against a committed `stdout.expected.jsonl`.
|
||||
2. The **re-persisted session JSONL**, normalized and compared with `session.jsonl`. The same fixture is both replay source and expected log. Prompt text is scrubbed; one scenario per header class pins readable prompt and tool content as described in the [header-pinning Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). Override scenarios derive model behavior solely from their sidecar.
|
||||
2. The **re-persisted session JSONL**, normalized and compared with `session.jsonl`. The same fixture is both replay source and expected log. Prompt and tool bulk are scrubbed; one scenario per header class pins the remaining header sequence. The pin owns readable prompt and tool-schema sidecars by default, or names another pin as either source when the complete sequence is identical, so each distinct sidecar version is committed once. Fixture guards reject duplicate sidecar content, and record/refresh rejects shared claimants that generate different bytes. The original header-pinning rationale is preserved in the [header-pinning Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). Override scenarios derive model behavior solely from their sidecar.
|
||||
|
||||
The surfaces are complementary: stdout covers the minimal automation wire, while JSONL covers loop, tool, and boundary structure that the wire intentionally omits.
|
||||
|
||||
Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Vitest updates only the stdout expected output; normalized session equality never overwrites the replay fixture.
|
||||
Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Record and refresh also store a generated workspace and its filesystem-resolved aliases as `{{cwd}}` in the replay fixture, so platform temp roots and random basenames do not affect recordings; authored temp paths and cwd values under an explicit `workspaceParent` remain literal. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Ordinary Vitest snapshot updates write only the stdout expected output; the explicit `record` and `refresh` modes own replay-fixture writes.
|
||||
|
||||
### Isolation: normalization now, sandbox later
|
||||
|
||||
@@ -76,9 +76,10 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log
|
||||
- **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral expected output.
|
||||
- **A byte-level HTTP-record library (Polly/nock/MSW)** — rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test.
|
||||
- **Synthesizing throw/cancel entries from `turn/end {kind:'error'|'aborted'}`** — rejected: it couples `llm-replay` to loop-internal turn-closing semantics, and the `turn/end` reason is lossy (it cannot distinguish a thrown 401 from a finish-error); the explicit `replay.override.json` sidecar is the cleaner seam.
|
||||
- **Copying both request-header sidecars beside every class pin** — rejected: prompt and tool-schema composition vary independently, so a change to one shared component would churn byte-identical files across unrelated class pins. Explicit per-component sources retain one structural pin per class without duplicating content.
|
||||
|
||||
## Consequences
|
||||
|
||||
The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it).
|
||||
The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it).
|
||||
|
||||
This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP.
|
||||
@@ -53,11 +53,11 @@ Status: implemented
|
||||
快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的:
|
||||
|
||||
1. **stdout transcript**——自动化客户端收到的、经过 framing 的 ACP JSON-RPC 响应与已提交的消息更新。它捕获传输契约的回归,与已提交的 `stdout.expected.jsonl` 比较。
|
||||
2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词文本会被清理;按照[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)所述,每种请求头类别由一个场景固定可读提示词与工具内容。Override 场景仅从其 sidecar 派生模型行为。
|
||||
2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词与工具的主体内容会被清理;每种请求头类别由一个场景固定余下的请求头序列。该 pin 默认拥有可读的提示词与工具 schema sidecar;当完整的对应序列相同时,也可将另一个 pin 指定为其中任一来源,因此每个不同的 sidecar 版本只提交一次。fixture 保护会拒绝重复的 sidecar 内容,录制/刷新会拒绝生成不同字节的共享引用方。最初的请求头固定理由保留在[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)中。Override 场景仅从其 sidecar 派生模型行为。
|
||||
|
||||
两个表面互补:stdout 覆盖精简的自动化线协议,JSONL 覆盖线协议有意省略的 loop、工具和 boundary 结构。
|
||||
|
||||
规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。Vitest 只更新 stdout 预期输出;规范化会话相等性检查从不覆盖重放 fixture。
|
||||
规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。录制与刷新还会在回放 fixture 中将生成的 workspace 及其文件系统解析出的别名存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。
|
||||
|
||||
### 隔离:当前靠归一化,后续可加沙箱
|
||||
|
||||
@@ -76,9 +76,10 @@ Status: implemented
|
||||
- **手工编写包含模型分片的 `llm.json`**——早期草案;复用真实会话日志,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。
|
||||
- **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSE(Server-Sent Events)时笨拙,且层级低于被测对象。
|
||||
- **从 `turn/end {kind:'error'|'aborted'}` 合成抛错/取消条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。
|
||||
- **在每个类别 pin 旁复制两个请求头 sidecar**:否决。提示词与工具 schema 的组合各自独立变化,因此一个共享组件发生变更,就会使不相关类别 pin 中字节完全相同的文件产生无意义改动。显式的分组件来源可在不重复内容的情况下,为每个类别保留一个结构性 pin。
|
||||
|
||||
## 后果
|
||||
|
||||
该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。
|
||||
该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。
|
||||
|
||||
本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用重放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。
|
||||
+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-25-scriptable-llm-wire-fault-server.md: 0795f71f0eab1a107740aaa8cba6fa04b1fbd306
|
||||
2026-07-25-scriptable-llm-wire-fault-server.zh.md: a0e3c98d729adcc74e6d98933ab2beb538f71cb3
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md
|
||||
2026-07-25-scriptable-llm-wire-fault-server.md: b8e64d92db224c199d0f8c547f0caa588d0ca65e
|
||||
2026-07-25-scriptable-llm-wire-fault-server.zh.md: 35b27efa99b625fa3c815bbe58fef6a138477e55
|
||||
@@ -12,7 +12,7 @@ Connection refusal, a reset before the first event, clean EOF without `[DONE]`,
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-llm-mock-server` is a support package with an importable Node HTTP server and a standalone CLI. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`.
|
||||
`@deepseek-ai/dsh-llm-mock-server` is a private support package with an importable Node HTTP server. The repository-local `pnpm run mock:llm` source entry provides a standalone process for manual fault injection; the package exposes no installable binary. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`.
|
||||
|
||||
Request behaviors cover socket reset, post-header disconnect, partial disconnect, stall, valid empty completion, clean truncated streams, malformed payloads, representative HTTP failures, complete text/reasoning/tool-call responses, slow streaming, and max-token completion. A true `connection_refused` is a CLI listener-lifecycle phase because a bound request handler cannot refuse its own TCP connection.
|
||||
|
||||
@@ -32,10 +32,12 @@ Package tests exercise every request behavior, split UTF-8 request decoding, HTT
|
||||
|
||||
**Use only an in-process `LlmAdapter` mock** — rejected because it bypasses fetch, HTTP status/header parsing, SSE framing, socket termination, and the adapter idle watchdog: the exact boundaries this test infrastructure exists to exercise.
|
||||
|
||||
**Expose an installable workspace binary** — rejected because pnpm links dependency binaries before repository build outputs exist, coupling clean installs to a test-only artifact. The repository-local source command supports the same manual fault injection without adding a package installation surface.
|
||||
|
||||
**Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Extending recovery to `STREAM_CLOSED` requires a separate decision with its own cost, latency, and duplicate-generation trade-offs.
|
||||
|
||||
## Consequences
|
||||
|
||||
Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and recovered empty completions without splicing attempts or modifying model history.
|
||||
|
||||
The server adds a support package, executable build entry, and behavior vocabulary that must remain compatible with both direct tests and CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval.
|
||||
The server adds a private support package and behavior vocabulary that must remain compatible with both direct tests and repository-local CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval.
|
||||
@@ -12,9 +12,9 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-llm-mock-server` 是一个支持包(package),提供可导入的 Node HTTP 服务器和独立 CLI(命令行界面)。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败;只有设置 `repeatLast` 才会重复最后一个行为。
|
||||
`@deepseek-ai/dsh-llm-mock-server` 是一个私有支持包(package),提供可导入的 Node HTTP 服务器。仓库内的 `pnpm run mock:llm` 源码入口提供一个用于手动故障注入的独立进程;该包不公开可安装的二进制命令。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败;只有设置 `repeatLast` 才会重复最后一个行为。
|
||||
|
||||
请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI 的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。
|
||||
请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI(命令行界面)的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。
|
||||
|
||||
脚本项 `random` 会为每个请求重新执行一次加权选择。服务器公开并记录其无符号 32 位 seed,允许调用方提供相对权重,并内置一套偏重成功结果的压力测试配置,将传输、协议、提供方、超时和语义空结果混合在一起。该配置用于提供可调的测试压力,并非对生产事故发生频率的估算;`connection_refused` 仍不进入请求级随机池。
|
||||
|
||||
@@ -32,10 +32,12 @@ Status: implemented
|
||||
|
||||
**仅使用进程内的 `LlmAdapter` mock**:不予采纳。它会绕过 fetch、HTTP 状态与 header 解析、SSE 分帧、socket 终止以及适配器的空闲看门狗,而这正是这套测试基础设施要覆盖的边界。
|
||||
|
||||
**公开可安装的 workspace 二进制命令**:不予采纳。pnpm 会在仓库构建产物存在之前链接依赖项的二进制命令,从而让干净安装与仅供测试的产物产生耦合。仓库内的源码命令支持相同的手动故障注入,而不会新增包安装接口。
|
||||
|
||||
**随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否将恢复能力扩展到 `STREAM_CLOSED`,需要单独决策,并权衡成本、延迟和重复生成风险。
|
||||
|
||||
## 后果
|
||||
|
||||
开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed,让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与恢复后的空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。
|
||||
|
||||
服务器新增了一个支持包、可执行构建入口和行为词汇,三者必须同时兼容直接测试与 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。
|
||||
服务器新增了一个私有支持包和一套行为词汇,二者必须同时兼容直接测试与仓库内的 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。
|
||||
+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/proposed/architecture/2026-07-27-session-projection-and-command-log.md
|
||||
2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5
|
||||
2026-07-27-session-projection-and-command-log.zh.md: 500f07968db049e4a174ff3b7a075bfe095283db
|
||||
@@ -0,0 +1,183 @@
|
||||
# Agent Note: Session projections and command lifecycle logging
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-27-session-projection-and-command-log.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Three in-flight web features — todo (#497), goal (#527), and plan mode (#587) — each derive per-session state from the session log and surface it in the browser client, and each invented its own copy of the same machinery:
|
||||
|
||||
- **The client core class absorbs every domain.** All three add private fields, fetch choreography, and event switches to the client runtime's `Session` class and project their values through `ConversationSnapshot`. Plan alone adds seven private fields and a three-layer fence (request version, event version, latest-live cache); goal adds a write-revision fence plus a coalesced refetch loop; todo adds a projection field and an event case. A fourth domain means editing the core class a fourth time.
|
||||
- **Three baseline channels.** Todo rides a `todos` field on the history tail page — computed by `backscanTodos` **inside api-proxy**, business folding living in the carrier; plan adds a dedicated `session.planMode` unary; goal adds `goals.get`. Same problem, three wire shapes.
|
||||
- **Command results are unrecoverable.** `/goal`, `/plan`, and every other slash command return their outcome only in the `command.execute` RPC response, surfaced as a transient composer notice on the issuing tab. Nothing reaches the session log: a refresh, another tab, resume, or fork loses the record that the command ever ran. The domain *state* changes are durable (goal commits `goal/change` metadata, plan commits `plan/mode`), but the command invocation and its verdict are not.
|
||||
|
||||
The underlying gap is architectural: the client has no seam for a plugin to observe session events in a session's scope and keep its own derived state, and the host has no uniform way to hand a client the current value of log-derived state whose history may have been paged out of the client's window.
|
||||
|
||||
## Proposal
|
||||
|
||||
Four infrastructure pieces, then the domains become pure contributors.
|
||||
|
||||
### Whole-value event rule
|
||||
|
||||
A state-carrying log event MUST carry the complete post-change state, never a bare delta. All three domains already comply: `todo/write` is a whole-list snapshot, `plan/mode` a whole boolean, `goal/change` metadata a full `GoalSnapshot` (or a whole-value clear tombstone). The rule keeps every domain's transition trivially cheap (the framework drives it per event), keeps values self-describing on the wire, and lets any consumer treat the latest pushed value as final — out-of-order immunity by seq comparison, self-healing because a missed update is corrected by the next one.
|
||||
|
||||
### Host projection registry (`dsh-session-projection`, new package)
|
||||
|
||||
A light interface package: the merge-extensible type map, the registry service, zod at the boundary. Capability-seam three-way split: domain host plugins contribute, carriers consume, neither knows the other.
|
||||
|
||||
What a domain registers is a **state-driven computation unit** — three pure functions plus declarations — never an opaque getter. The framework owns driving it (subscription, watermark, caching, and later checkpointing); the domain owns only the mathematics. Projections serve every business domain (session title, plan, goal, permission, todos); commands are merely one trigger path and hold no special position in this contract.
|
||||
|
||||
```ts ignore-check
|
||||
export interface SessionProjectionMap {} // the single type table for the whole chain
|
||||
|
||||
export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
|
||||
key: K
|
||||
schema: ZodType<SessionProjectionMap[K]> // validates the payload before it leaves the host
|
||||
/** State for the empty log. */
|
||||
init(): S
|
||||
/** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
|
||||
apply(state: S, event: SessionEvent): S
|
||||
/** State → wire payload (the read-side projection). */
|
||||
view(state: S): SessionProjectionMap[K]
|
||||
/** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
|
||||
stateVersion: number
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context { sessionProjections: SessionProjectionRegistry }
|
||||
}
|
||||
```
|
||||
|
||||
- Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
|
||||
- **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code.
|
||||
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
|
||||
- A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs).
|
||||
- Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
|
||||
- The package owns `./invariant` (every served key has a live registration).
|
||||
|
||||
### Wire: projections block on the history tail page
|
||||
|
||||
```ts ignore-check
|
||||
// session.history response, tail page only (beforeSeq absent):
|
||||
{ events, hasMore,
|
||||
projections?: { asOfSeq: number, values: Partial<SessionProjectionMap> } }
|
||||
```
|
||||
|
||||
The api-proxy history handler, after slicing the tail page, synchronously walks the registry — no `await` anywhere, so every key's value and `asOfSeq` form one consistent cut. `asOfSeq` is the **last event's seq** (`session.seq - 1`; `-1` for an empty log, the same vocabulary as `session/subscribed.lastSeq`), so a push frame carrying the first post-baseline change always compares strictly greater. Api-proxy holds zero domain knowledge (the same carrier/contributor relationship as `viewFor` against `ctx.tools`).
|
||||
|
||||
No new RPC method. The timing coincidence is exact: every moment the client needs a fresh baseline (open, reconnect resync, gap repair) already pulls the tail page, and the only path that never needs one (loadOlder) is the only path that passes `beforeSeq`. The client therefore has **no** independent "refetch the baseline" decision at all. Window content is never a signal: "no domain event in the window" is unanswerable there by construction, and only the baseline answers it.
|
||||
|
||||
Retired by this block: `session.planMode` and `setPlanMode` (both sides — plan selection goes through the standard command channel, see the plan section), `goals.get` (read side; the six mutation RPCs stay, their responses no longer feed state — the mux event arrives anyway), the `todos` rider field, and `backscanTodos` in api-proxy (moves into the todo domain's unit, in `tool-todo`).
|
||||
|
||||
### Push frame and the client value store (domains write zero client code)
|
||||
|
||||
Because the host is the only computation site, finished values reach clients over one new mux frame:
|
||||
|
||||
```ts ignore-check
|
||||
// MuxFrame union + schema branch:
|
||||
{ type: 'session/projection', sessionId, key: string, value: unknown, seq: number }
|
||||
```
|
||||
|
||||
The framework emits it whenever a unit's state reference changes (`Object.is` gate above); `seq` is the unit's watermark at emission. This is live push state, never logged — the same posture as the tool-view `view` slot: replay recomputes on the host.
|
||||
|
||||
The client object layer keeps one **generic value store** per session: `key → { value, seq }`, seeded by the tail page's projections block and updated by the frame, under the single rule **higher seq wins**. Replayed baselines cannot roll a newer frame back; a lost frame costs staleness until the next frame or baseline, never wrongness. No `fromEvent`, no per-domain cell registration, no client-side domain folding — a domain ships projection support with **zero client code** (the `SessionProjectionMap` merge serves both sides through the `/types` outlet). The bespoke `session/title` frame and the manager's title-snapshot map retire into this generic pair. All the per-domain fences (#587's three layers, #527's write revision) dissolve into the one seq rule.
|
||||
|
||||
### Plan through the standard command channel (worked example)
|
||||
|
||||
Plan mode demonstrates the full pattern — trigger path, run plane, and replay plane, cleanly separated:
|
||||
|
||||
- **Trigger path**: the web plan toggle sends `/plan` / `/plan off` through `command.execute` like any other command; the dedicated `setPlanMode`/`planMode` RPCs are retired. The user's *request* is durably recorded as that command's `command/run { name: 'plan', args: 'off' | '' }` — structured fields, no line parsing.
|
||||
- **Run plane** (unchanged): the plan-mode service keeps its in-memory pending intent and flushes `plan/mode` at the next turn boundary. On cold start the service rebuilds its intent queue from the replay plane ("empty run state means the replay state").
|
||||
- **Replay plane**: plan's projection unit folds **two** event types — its own `command/run` records set `wanted`; `plan/mode` sets `active` and clears `wanted`; `view` derives `{ active, pending: wanted !== null && wanted !== active }`. Pending is thereby a pure replay quantity: host restarts recover it, other tabs fold the same events (cross-tab pending for free), and a cold read answering `{ active: false, pending: true }` is accurate ("an unfulfilled selection awaits resume").
|
||||
|
||||
A domain's input event set is its own choice — that is the general rule this example instantiates. Whether "the user asked for X" appears in a projection (plan folds its command records) or only in the flow (the command node renders anyway) is per-domain semantics, never a framework concern.
|
||||
|
||||
### React: `useProjection`, the fifth framework hook seat
|
||||
|
||||
The existing four seats cannot host this state (store discipline bans business objects; inject bans hooks; `ConversationSnapshot` is being evacuated). `useProjection` becomes a framework seat, minted in web-react (the one hook constructor), delivered through the same standard-kit channel as `useSession` (`provideInfo` → SessionProvider → props):
|
||||
|
||||
```ts ignore-check
|
||||
type UseProjection = {
|
||||
<K extends keyof SessionProjectionMap>(key: K): SessionProjectionMap[K] | undefined
|
||||
<K extends keyof SessionProjectionMap, S>(
|
||||
key: K, selector: (v: SessionProjectionMap[K] | undefined) => S,
|
||||
eq?: (a: S, b: S) => boolean): S
|
||||
}
|
||||
```
|
||||
|
||||
`undefined` uniformly means capability absent (host plugin unmounted, or no baseline/frame has carried the key). The value store exposes bare per-key `{subscribe, getSnapshot}` faces; `bindSnapshotSelector` with per-key caching does the rest — reference stability holds because a key's value reference changes only when a frame or baseline lands. Write paths are unchanged: mutation callbacks stay in the inject share (callbacks out of inject, live state out of `useProjection`).
|
||||
|
||||
The one existing violation of "no hooks through inject" — `DetailsInjected.useSelection` — is folded in with this change: selection is viewing state living in the chat store, so the details registration declares the shared store handle and the component reads `props.useStore(s => s.selection)`; `useSelection` leaves the inject contract.
|
||||
|
||||
### Command lifecycle in the log
|
||||
|
||||
Two log-only (non-surface, model-invisible) events, mirroring the `tool/call`/`tool/result` pairing:
|
||||
|
||||
```ts ignore-check
|
||||
'command/run': { commandId: string; name: string; args: string; source: CommandSource }
|
||||
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
|
||||
```
|
||||
|
||||
The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged.
|
||||
|
||||
Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to admission — `{ matched, commandId? }`: whether the line resolved, and the minted pairing id when it did, so the issuing client can correlate its request with the flow node the lifecycle events produce. The one-shot notice channel (`runDetached` → `noticeFor`) is retired.
|
||||
|
||||
The client flow builder gains one generic command node (run/done paired by `commandId`; cross-window cuts soft-fall like tool pairs). Rendering goes through a new keyed slot `'conversation.chat.commandview'`, key = command name, **fallback = a generic command card** (zero registration required — the former notice text now renders durably in the flow). A domain upgrades by registering one row component, drawing on `command/run`'s structured fields and its own projection value (`useProjection`) — the same shape as tool rows after the toolview dissolution.
|
||||
|
||||
## Delivery plan
|
||||
|
||||
Infrastructure first; the three in-flight PRs are left untouched and re-target after the base lands (their migration mapping is the guide):
|
||||
|
||||
1. **Host base**: `dsh-session-projection` (unit contract, eager drive, watermark cache) + api-proxy projections block + the `session/projection` push frame. Mergeable with zero domains registered (block and frames simply absent).
|
||||
2. **Client base**: the generic value store + `useProjection` seat; retire the per-domain cell machinery and, with title's unit registered, the `session/title` frame and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile).
|
||||
3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement, `{matched, commandId?}` admission. Parallel with 1.
|
||||
4. **Domain re-targets** (after 1+2): todo (unit in `tool-todo`, drop the rider field), then plan (two-event unit, RPCs retired, toggle → `/plan`), then goal (`goal/change` unit, drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
|
||||
5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, ver, seq, val)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A dedicated `session.projections` RPC** — rejected: baseline-refresh moments coincide exactly with tail-page pulls, so a separate unary buys a second round-trip, a second seq to reconcile, and a client-side "when to refetch" decision that the rider design deletes outright.
|
||||
|
||||
**An opaque `get(agent)` provider contract** — rejected after being the first draft: with the computation model hidden inside the domain, the framework can never checkpoint the state, serve cold sessions (no agent, no loaded log — `get` has nothing to run against), or resume from a mid-log position. Registering the `(init, apply, view)` unit hands the framework the drive and keeps the domain to pure mathematics; a domain with host-side behavioral needs still keeps its own service subscriptions independently of the projection unit.
|
||||
|
||||
**A live-only overlay hook (`live?(agent, base)`) for plan's pending intent** — rejected: it existed solely because the user's plan *selection* was not in the log. Routing the selection through the standard command channel puts `command/run` on the account, pending becomes a pure replay quantity, and the projection contract stays exactly three pure functions.
|
||||
|
||||
**Naming the seam `registerFold`** — superseded by the unit contract: the registered object now genuinely is a fold, but `fold*` in this repo names pure `(events) => state` helper functions while this seam registers a keyed, schema'd, versioned unit. Projection remains the event-sourcing term for the read-model role, and both #587's note title and #497's comments already use it.
|
||||
|
||||
**Client-side folding (per-domain projection cells with a `fromEvent`)** — rejected after being the second draft: once plan's unit folds two event types, a client cell must duplicate the host's transition logic in the browser — the same fold written twice, evolving separately. Pushing finished values (the title-frame precedent, generalized) keeps one computation site and reduces the client to a generic seq-guarded value store; domains write zero client code.
|
||||
|
||||
**Bounded reverse scan over the log tail (absorber declarations)** — rejected for now: nothing supports it today, it only serves domains whose every event carries the full folded state, and the persisted projection cache covers the same cold-read need uniformly (cache row + forward tail replay — the same recipe as the client's baseline + catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve.
|
||||
|
||||
**An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: it exists only to serve delta events. The whole-value rule makes every domain last-wins; goal's refetch loop, its coalescing, and its stale-read fence all disappear.
|
||||
|
||||
**Hanging the registry off `ctx.apiProxy`** — rejected: session projections are not web-specific (TUI, ACP, headless are future consumers), and domain packages must not depend on the apiproxy package. The independent seam also deletes #587's type-only import edge from api-proxy into the plan package.
|
||||
|
||||
**A separate client-side `SessionProjectionViews` type table** — rejected: one `SessionProjectionMap` typed end to end is the wire-passthrough discipline (no second DTO vocabulary); values are JSON payloads and rendering belongs to slots.
|
||||
|
||||
**Event-broadcast collection instead of a registry walk** — rejected: async listeners cannot yield the single synchronous cut that makes `asOfSeq` one consistent snapshot across all keys; registries are this repo's shape for contributions (`ctx.tools`, prompt sections, slots).
|
||||
|
||||
**A dedicated `plan/select` selection event (structured domain event instead of folding command records)** — rejected in favor of the command channel: `command/run`'s structured `{name, args}` already records the selection, the `/plan` grammar and its fold live in the same plugin (domain-internal coupling, not cross-domain), and one less event type. The handler must call `set()` before any failable path so the logged request and the run plane cannot diverge — a domain-internal ordering constraint, documented at the handler.
|
||||
|
||||
**Keeping `setPlanMode` as a dedicated RPC** — rejected: plan selection is a user command like any other; the command channel gives it durable recording, flow rendering, multi-tab visibility, and admission semantics without a bespoke wire method. Web UI affordances (a toggle) compose the command line internally.
|
||||
|
||||
**Making mutation RPC responses feed cell state** — rejected: the committed mux event arrives immediately and carries the same whole value with a seq; responses feeding state is what required #527's write-revision fence.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A domain plugin ships per-session log-derived state to React by writing only: the whole-value event declaration, one host unit `register`, its `SessionProjectionMap` merge, and inject callbacks — zero client-side code, no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files.
|
||||
- The history tail page carries `projections` with `asOfSeq` equal to the window tail seq; loadOlder pages never carry it; a deployment without the registry serves histories without the block and clients treat every key as absent.
|
||||
- A stale baseline cannot overwrite a newer `session/projection` frame, and a replayed frame cannot regress the value store (higher-seq-wins tests on both paths).
|
||||
- A slash command executed on one tab renders a durable node in the flow on refresh, on a second tab, and after resume; unregistered commands render the generic card; the composer notice path for command outcomes is gone.
|
||||
- `useProjection` reaches components through the standard props kit; no hook crosses an inject contract (including `useSelection`).
|
||||
- Session titles ride the generic pair (baseline block + projection frame); the bespoke `session/title` frame and the client title-snapshot map are gone.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Whole-value rule is load-bearing**: a future domain logging bare deltas cannot serve consumers from its latest event and complicates its own unit. Mitigation: the rule is stated here and in the projection package README; the unit contract makes the full state explicit at every transition.
|
||||
- **Synchronous unit discipline**: `init`/`apply`/`view` that await would tear the consistency cut. The registry documents and the invariant companion asserts synchronicity as far as practical; review owns the rest.
|
||||
- **Live registry churn is not pushed**: loading or unloading a domain plugin mid-session changes the key set, but no session event fires and no frame is pushed; open clients hold the stale key until the next tail pull (reconnect, gap repair, open). Accepted as a dev-only (HMR) staleness window — a registry-change push can be added to the change feed later without contract impact.
|
||||
- **Eager drive costs on busy sessions**: every committed event passes every registered unit's `apply`. Units are cheap per-event by construction (whole-value rule), non-matching events return the same reference, and the count of registered domains is small; if a hot path ever shows, per-unit event-type prefilters can be added without contract change.
|
||||
- **Projection payload growth**: every tail page carries every registered key. Payloads are whole values of UI-scale state (a todo list, a goal snapshot); if a future domain's value is large, per-key opt-out or lazy keys can be added to the request without changing the model.
|
||||
- **Command log volume**: two log-only events per slash command; bounded by human command frequency, negligible against chunk volume.
|
||||
- **Re-target churn**: three open PRs rebase onto a moved foundation. Accepted cost of infrastructure-first; the migration mapping section in the design ledger names each PR's keep/drop list.
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# Agent Note: Session projections and command lifecycle logging
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-27-session-projection-and-command-log.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
三个在途的 web 功能——todo(#497)、goal(#527)、plan mode(#587)——都要从会话日志推导按会话的状态并呈现到浏览器客户端,而三者各自发明了一套同样的机制:
|
||||
|
||||
- **客户端核心类吸收每一个领域。** 三者都往客户端运行时的 `Session` 类里添加私有字段、拉取编排和事件 switch 分支,并经 `ConversationSnapshot` 投出各自的值。仅 plan 一家就加了七个私有字段和三层栅栏(请求版本、事件版本、最新活值缓存);goal 加了写 revision 栅栏外加一个合并式重取循环;todo 加了一个投影(projection)字段和一条事件 case 分支。再来第四个领域,就要第四次改动核心类。
|
||||
- **三条基线通道。** todo 搭在历史尾页的 `todos` 字段上——由 **api-proxy 内部**的 `backscanTodos` 计算,业务折叠(fold)逻辑寄居在载体里;plan 加了一个专用的 `session.planMode` 一元 RPC;goal 加了 `goals.get`。同一个问题,三种协议格式(wire format)。
|
||||
- **命令结果不可恢复。** `/goal`、`/plan` 以及其余所有斜杠命令都只在 `command.execute` RPC 响应里返回结果,以一条转瞬即逝的 composer 通知呈现在发起命令的标签页上。会话日志里什么也留不下:刷新、另开标签页、恢复或 fork 都会丢掉「该命令曾经运行过」的记录。领域*状态*变更是持久的(goal 提交 `goal/change` 元数据,plan 提交 `plan/mode`),但命令调用本身及其结论不是。
|
||||
|
||||
底层缺口是架构性的:客户端没有一个 seam 让插件在会话 scope 内观察会话事件并维护自己的派生状态;host 侧也没有统一的方式把日志派生状态的当前值交给客户端——而该状态的历史可能已被分页挤出客户端窗口之外。
|
||||
|
||||
## Proposal
|
||||
|
||||
先立四件基础设施,之后各领域都退化为纯贡献方。
|
||||
|
||||
### 全量值事件规则
|
||||
|
||||
携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。三个领域现状已然合规:`todo/write` 是整表快照,`plan/mode` 是一个完整布尔值,`goal/change` 元数据是完整的 `GoalSnapshot`(或一个全量值清除墓碑)。该规则让每个领域的状态转移始终足够廉价(框架逐事件驱动它),让值在协议层自描述,并让任何消费方都可以把最近推送的值当作最终值——靠 seq 比较获得乱序免疫,且自愈:漏掉的更新会被下一次更新纠正。
|
||||
|
||||
### host 侧投影注册表(`dsh-session-projection`,新包)
|
||||
|
||||
一个轻量的接口包(package):merge-extensible 类型表、注册表服务、边界上的 zod 校验。能力 seam 三方拆分:领域 host 插件负责贡献,载体负责消费,两侧互不相识。
|
||||
|
||||
领域注册的是一个**状态驱动计算单元(state-driven computation unit)**——三个纯函数外加若干声明——绝不是一个不透明的 getter。驱动它是框架的职责(订阅、水位线(watermark)、缓存,以及后续的检查点机制),领域只负责数学本身。投影服务于所有业务领域(会话标题、plan、goal、权限、todos);命令只是其中一条触发路径,在本契约中没有任何特殊地位。
|
||||
|
||||
```ts ignore-check
|
||||
export interface SessionProjectionMap {} // the single type table for the whole chain
|
||||
|
||||
export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
|
||||
key: K
|
||||
schema: ZodType<SessionProjectionMap[K]> // validates the payload before it leaves the host
|
||||
/** State for the empty log. */
|
||||
init(): S
|
||||
/** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
|
||||
apply(state: S, event: SessionEvent): S
|
||||
/** State → wire payload (the read-side projection). */
|
||||
view(state: S): SessionProjectionMap[K]
|
||||
/** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
|
||||
stateVersion: number
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context { sessionProjections: SessionProjectionRegistry }
|
||||
}
|
||||
```
|
||||
|
||||
- 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 侧单元、协议块、React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
|
||||
- **host 是投影唯一的计算地点。** 框架正向驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。
|
||||
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
|
||||
- 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。
|
||||
- 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
|
||||
- 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
|
||||
|
||||
### 协议层:历史尾页上的 projections 块
|
||||
|
||||
```ts ignore-check
|
||||
// session.history response, tail page only (beforeSeq absent):
|
||||
{ events, hasMore,
|
||||
projections?: { asOfSeq: number, values: Partial<SessionProjectionMap> } }
|
||||
```
|
||||
|
||||
api-proxy 的历史处理器切出尾页后同步遍历注册表——全程没有一个 `await`,因此所有 key 的值与 `asOfSeq` 构成同一个一致切面。`asOfSeq` 是**最后一个事件的 seq**(`session.seq - 1`;空日志为 `-1`,与 `session/subscribed.lastSeq` 同一套词汇),因此携带基线之后首个变更的推送帧在比较时恒严格更大。api-proxy 不持有任何领域知识(与 `viewFor` 面向 `ctx.tools` 是同一种载体/贡献方关系)。
|
||||
|
||||
不新增 RPC 方法。时机上的重合是精确的:客户端每一个需要新基线的时刻(打开、重连重同步、缺口修补)本来就要拉尾页,而唯一永远不需要基线的路径(loadOlder)恰好是唯一传 `beforeSeq` 的路径。因此客户端**完全没有**独立的「重取基线」决策。窗口内容从不充当信号:「窗口里没有该领域的事件」这个问题在窗口内从构造上就无法回答,只有基线能回答它。
|
||||
|
||||
随此块下线的旧通道:`session.planMode` 与 `setPlanMode`(读写两侧——plan 选择改走标准命令通道,见 plan 一节)、`goals.get`(读侧;六个变更 RPC 保留,但其响应不再喂状态——mux 事件反正会到)、`todos` 搭载字段,以及 api-proxy 里的 `backscanTodos`(移入 todo 领域的单元,落在 `tool-todo`)。
|
||||
|
||||
### 推送帧与客户端值仓(领域零客户端代码)
|
||||
|
||||
既然 host 是唯一计算地点,成品值经一个新的 mux 帧送达客户端:
|
||||
|
||||
```ts ignore-check
|
||||
// MuxFrame union + schema branch:
|
||||
{ type: 'session/projection', sessionId, key: string, value: unknown, seq: number }
|
||||
```
|
||||
|
||||
只要某单元的状态引用发生变化(上文的 `Object.is` 闸门),框架就发出该帧;`seq` 是发出时该单元的水位线。这是实时推送状态,绝不入日志——与 tool-view 的 `view` slot 同一姿态:回放时在 host 重新计算。
|
||||
|
||||
客户端对象层为每个会话维护一个**通用值仓(value store)**:`key → { value, seq }`,由尾页的 projections 块播种、由该帧更新,唯一规则是 **seq 高者胜**。重放的基线无法把更新的帧往回滚;丢失一个帧的代价只是陈旧——到下一个帧或基线为止——绝不会出错。没有 `fromEvent`,没有按领域的 cell 注册,没有客户端侧领域折叠——领域交付投影支持只需**零客户端代码**(`SessionProjectionMap` merge 经 `/types` 出口同时服务两侧)。专设的 `session/title` 帧与 manager 的标题快照表都收编进这对通用机制。所有按领域自造的栅栏(#587 的三层、#527 的写 revision)都消融进这一条 seq 规则。
|
||||
|
||||
### plan 走标准命令通道(完整示例)
|
||||
|
||||
plan mode 完整演示了这套模式——触发路径、运行面、回放面,三者干净分离:
|
||||
|
||||
- **触发路径**:web 的 plan 开关像任何其他命令一样经 `command.execute` 发送 `/plan` / `/plan off`;专设的 `setPlanMode`/`planMode` RPC 下线。用户的*请求*被持久记录为该命令的 `command/run { name: 'plan', args: 'off' | '' }`——结构化字段,无需解析行文本。
|
||||
- **运行面**(不变):plan-mode 服务在内存里保持待定意图,并在下一个轮次边界落下 `plan/mode`。冷启动时服务从回放面重建其意图队列(「运行态为空即以回放态为准」)。
|
||||
- **回放面**:plan 的投影单元折叠**两**种事件——它自己的 `command/run` 记录设置 `wanted`;`plan/mode` 设置 `active` 并清除 `wanted`;`view` 推导出 `{ active, pending: wanted !== null && wanted !== active }`。待定态由此成为纯回放量:host 重启能恢复它,其他标签页折叠同样的事件(跨标签页待定态随之自动获得),冷读回答 `{ active: false, pending: true }` 也是准确的(「一个未兑现的选择正等待恢复」)。
|
||||
|
||||
领域的输入事件集由领域自己选择——本示例落实的正是这条一般规则。「用户请求过 X」是出现在投影里(plan 折叠自己的命令记录),还是只出现在 flow 里(命令节点反正会渲染),属于各领域自己的语义,永远不是框架的关切。
|
||||
|
||||
### React:`useProjection`,第五个框架钩子席位
|
||||
|
||||
既有四个席位都装不下这份状态(store 纪律禁止业务对象;inject 禁止钩子;`ConversationSnapshot` 正在被清退)。`useProjection` 成为一个框架席位,在 web-react(唯一的钩子铸造点)铸造,经与 `useSession` 相同的标准套件通道(`provideInfo` → SessionProvider → props)送达:
|
||||
|
||||
```ts ignore-check
|
||||
type UseProjection = {
|
||||
<K extends keyof SessionProjectionMap>(key: K): SessionProjectionMap[K] | undefined
|
||||
<K extends keyof SessionProjectionMap, S>(
|
||||
key: K, selector: (v: SessionProjectionMap[K] | undefined) => S,
|
||||
eq?: (a: S, b: S) => boolean): S
|
||||
}
|
||||
```
|
||||
|
||||
`undefined` 统一表示能力缺失(host 插件未挂载,或尚无任何基线/帧携带过该 key)。值仓只暴露按 key 的裸 `{subscribe, getSnapshot}` 面;其余交给带逐 key 缓存的 `bindSnapshotSelector`——引用稳定性成立,因为一个 key 的值引用只在帧或基线落地时才变化。写路径不变:变更回调留在 inject 共享面(回调出自 inject,活状态出自 `useProjection`)。
|
||||
|
||||
「钩子不得穿过 inject」的唯一既有违例——`DetailsInjected.useSelection`——随本变更一并收编:选中态是住在聊天 store 里的查看状态,因此 details 注册声明共享 store 句柄,组件改读 `props.useStore(s => s.selection)`;`useSelection` 退出 inject 契约。
|
||||
|
||||
### 日志中的命令生命周期
|
||||
|
||||
两个仅日志(非 surface、模型不可见)事件,镜像 `tool/call`/`tool/result` 的配对:
|
||||
|
||||
```ts ignore-check
|
||||
'command/run': { commandId: string; name: string; args: string; source: CommandSource }
|
||||
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
|
||||
```
|
||||
|
||||
host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent 的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)之后所有插件自有 log-only 事件同一形状:没有轮次包裹它们(轮次只描述模型循环执行),持久化在常规检查点排空它们,run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 与 `args` 就是解析器自己的切分(`parseCommand` 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域继续做它们今天在做的事(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。
|
||||
|
||||
由于已提交事件会在 mux 流上广播,刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为准入判定——`{ matched, commandId? }`:该行是否匹配命中,以及命中时新铸的配对 id,发起命令的客户端据此把自己的请求与生命周期事件产出的 flow 节点关联起来。一次性通知通道(`runDetached` → `noticeFor`)就此下线。
|
||||
|
||||
客户端 flow 构建器新增一个通用命令节点(run/done 按 `commandId` 配对;跨窗口截断时与工具配对同样软降级)。渲染走一个新的 keyed slot `'conversation.chat.commandview'`,key = 命令名,**兜底 = 通用命令卡片**(零注册即可用——从前的通知文本现在持久地渲染在 flow 里)。领域要升级展示,只需注册一个行组件,取材于 `command/run` 的结构化字段与自己的投影值(`useProjection`)——与 toolview 解散之后的工具行同一形状。
|
||||
|
||||
## Delivery plan
|
||||
|
||||
基础设施先行;三个在途 PR(Pull Request)原样不动,待基座落地后重新对接(它们的迁移映射即指南):
|
||||
|
||||
1. **host 基座**:`dsh-session-projection`(单元契约、正向驱动、水位线缓存)+ api-proxy 的 projections 块 + `session/projection` 推送帧。零领域注册也可合入(此时块与帧直接缺席)。
|
||||
2. **客户端基座**:通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1(在此之前 fixture(测试前置数据)喂合成帧)。
|
||||
3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。
|
||||
4. **领域重新对接**(在 1+2 之后):先 todo(单元进 `tool-todo`,删掉搭载字段),再 plan(双事件单元、RPC 下线、开关改发 `/plan`),最后 goal(`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject)。
|
||||
5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, ver, seq, val)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**专设一个 `session.projections` RPC**——不予采纳:基线刷新时刻与尾页拉取精确重合,单独的一元 RPC 只会换来第二次往返、第二个待调和的 seq,以及一个客户端「何时重取」决策——而搭载设计把这个决策整个删掉了。
|
||||
|
||||
**不透明的 `get(agent)` 提供方契约**——曾是第一稿,后被否决:计算模型藏在领域内部时,框架永远无法为状态做检查点、无法服务冷会话(没有 agent、没有已加载的日志——`get` 无处可跑)、也无法从日志中段续算。注册 `(init, apply, view)` 单元把驱动权交给框架,领域只留纯数学;有 host 侧行为需求的领域,其服务订阅照旧自持,与投影单元互不牵连。
|
||||
|
||||
**为 plan 待定意图专设的仅实时叠加钩子(`live?(agent, base)`)**——不予采纳:它存在的唯一理由是用户的 plan *选择*不在日志里。让选择走标准命令通道后,`command/run` 上了账,待定态成为纯回放量,投影契约保持恰好三个纯函数。
|
||||
|
||||
**把 seam 命名为 `registerFold`**——已被单元契约取代:注册对象如今确实是一个折叠,但本仓库里 `fold*` 专指纯 `(events) => state` 辅助函数,而该 seam 注册的是带 key、带 schema、带版本的单元。投影仍是事件溯源中指称读模型角色的术语,#587 的 Note 标题与 #497 的评论也都已在使用它。
|
||||
|
||||
**客户端侧折叠(带 `fromEvent` 的按领域投影 cell)**——曾是第二稿,后被否决:一旦 plan 的单元要折叠两种事件,客户端 cell 就必须在浏览器里复刻 host 的状态转移逻辑——同一个折叠写两遍、各自演化。推送成品值(标题帧先例的泛化)保住唯一计算地点,并把客户端简化为一个由 seq 把守的通用值仓;领域零客户端代码。
|
||||
|
||||
**对日志尾部的有界反向扫描(absorber 声明)**——暂不采纳:今天没有任何东西需要它,它只服务于「每个事件都携带完整折叠状态」的领域,而持久投影缓存以统一方式覆盖同一冷读需求(缓存行 + 正向尾部回放——与客户端的基线 + 追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。
|
||||
|
||||
**`invalidate` 式 cell(标脏,遇领域事件就重取)**——不予采纳:它的存在只为伺候增量事件。全量值规则让每个领域都是 last-wins;goal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。
|
||||
|
||||
**把注册表挂到 `ctx.apiProxy` 名下**——不予采纳:会话投影并非 web 专属(TUI、ACP(Agent Client Protocol)、headless 都是未来消费方),且领域包不得依赖 apiproxy 包。独立 seam 还顺带删掉了 #587 从 api-proxy 指向 plan 包的 type-only 导入边。
|
||||
|
||||
**独立的客户端 `SessionProjectionViews` 类型表**——不予采纳:一张 `SessionProjectionMap` 端到端贯通正是协议直通纪律(不设第二套 DTO 词汇);值就是 JSON 载荷,渲染归 slot 管。
|
||||
|
||||
**用事件广播收集、替代注册表遍历**——不予采纳:异步监听器给不出那个单一的同步切面,而正是它让 `asOfSeq` 成为横跨所有 key 的一致快照;注册表才是本仓库承接贡献的通行形状(`ctx.tools`、提示词片段、slot)。
|
||||
|
||||
**专设 `plan/select` 选择事件(用结构化领域事件替代折叠命令记录)**——不予采纳,改用命令通道:`command/run` 的结构化 `{name, args}` 已经记录了选择,`/plan` 的语法与其折叠逻辑同住一个插件(领域内耦合,非跨领域),还少一种事件类型。处理器必须在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉——这是领域内部的顺序约束,文档写在处理器处。
|
||||
|
||||
**保留 `setPlanMode` 专用 RPC**——不予采纳:plan 选择就是一条普通的用户命令;命令通道给它持久记录、flow 渲染、多标签页可见性与准入语义,不需要专设协议方法。Web UI 的交互组件(一个开关)在内部拼出命令行即可。
|
||||
|
||||
**让变更 RPC 的响应喂 cell 状态**——不予采纳:已提交的 mux 事件即刻到达,携带同一个全量值外加 seq;「响应喂状态」正是当初逼出 #527 写 revision 栅栏的根源。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 领域插件把按会话的日志派生状态送达 React,只需写:全量值事件声明、一次 host 侧单元 `register`、自己那份 `SessionProjectionMap` merge、以及 inject 回调——零客户端侧代码,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。
|
||||
- 历史尾页携带 `projections`,其 `asOfSeq` 等于窗口尾部 seq;loadOlder 页永不携带;未装注册表的部署照常返回不带该块的历史,客户端把所有 key 视为缺席。
|
||||
- 陈旧的基线不能覆盖更新的 `session/projection` 帧,重放的帧也不能让值仓倒退(两条路径都做 seq 高者胜测试)。
|
||||
- 在一个标签页执行的斜杠命令,刷新后、在第二个标签页上、恢复之后都在 flow 中渲染出持久节点;未注册的命令渲染通用卡片;命令结果的 composer 通知路径彻底移除。
|
||||
- `useProjection` 经标准 props 套件抵达组件;没有任何钩子穿过 inject 契约(包括 `useSelection`)。
|
||||
- 会话标题搭乘这对通用机制(基线块 + 投影帧);专设的 `session/title` 帧与客户端标题快照表彻底移除。
|
||||
|
||||
## Risks
|
||||
|
||||
- **全量值规则是承重结构**:未来某个领域若只记裸增量,就无法凭其最新事件服务消费方,还会让自己的单元复杂化。缓解:该规则写明在本 Note 与投影包的 README 里;单元契约让完整状态在每次转移处都是显式的。
|
||||
- **单元的同步纪律**:`init`/`apply`/`view` 一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律,invariant 配套在可行范围内断言同步性;其余由评审把关。
|
||||
- **注册表的实时增删不做推送**:会话中途加载或卸载领域插件会改变键集,但不会触发任何会话事件、也不会推任何帧;开着的客户端持有陈旧的 key 直到下次尾页拉取(重连、缺口修补、打开)。接受为仅开发期(HMR)的陈旧时窗——日后可以在变更流上加一个注册表变更推送,契约不受影响。
|
||||
- **忙碌会话上的正向驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。按构造,单元的逐事件开销很低(全量值规则),不匹配的事件返回同一引用,且已注册领域的数量很小;若真出现热点路径,可以加按单元的事件类型预过滤,契约不变。
|
||||
- **投影载荷膨胀**:每个尾页携带每个已注册的 key。载荷是 UI 量级状态的全量值(一张 todo 清单、一份 goal 快照);将来若某领域的值很大,可以在请求上加逐 key 的 opt-out 或惰性 key,模型本身不用改。
|
||||
- **命令日志体量**:每条斜杠命令两个仅日志事件;上限由人敲命令的频率决定,相对分片体量可忽略不计。
|
||||
- **重新对接的返工**:三个未合入的 PR 要变基到挪动后的地基上。这是基础设施先行的既定代价;设计台账中的迁移映射一节逐一列出每个 PR 的保留/删除清单。
|
||||
+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/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
|
||||
2026-07-28-storage-root-and-derived-medium-recovery.md: 06fa98b10dc5ac3164d8905e7005a42d9e99ae92
|
||||
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: b7bd18ffdbfaf412d9a91940cf1770e273f5b847
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Agent Note: Storage root placement and derived-medium recovery
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack.
|
||||
|
||||
**Where the files actually live.** The shipped composition gives the json backend a relative root — `root: './.storages'` (apps/cli/cordis.yml) — and `AppCLIEntry.composePatches` patches only the session store's root to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`, profile-overridable via `persistenceRoot`); no equivalent patch or profile key exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `<launch dir>/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
|
||||
|
||||
**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
|
||||
|
||||
## Proposal
|
||||
|
||||
Two independent changes, one per gap.
|
||||
|
||||
### One global storage root, resolved once
|
||||
|
||||
- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')` — `~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today.
|
||||
- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
|
||||
- Pre-release stance applies: no migration shim. A deployment that cached under `<cwd>/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
|
||||
|
||||
### Declared derived media: reset instead of reject
|
||||
|
||||
- `DomainSpec` gains `recovery?: 'reject' | 'reset'` (default `'reject'`). The spec object is already the single source of a domain's identity and layout; whether its medium is authoritative or derived is the same kind of fact and lives in the same place. `session_projcache` declares `'reset'`; `workspace` stays on the default.
|
||||
- `KvFacet` gains one primitive: `destroy(descriptor): Promise<void>` — remove the unit's medium entirely (json: delete the file; sqlite: drop the unit's tables). Like `open`, it is a backend storage primitive, not policy.
|
||||
- `DomainFacility.open`, when a spec declares `'reset'` and the open fails with exactly a damage-class error — `StorageError('version-mismatch' | 'malformed-medium')` or `DomainError('invalid-record')` — logs one warning naming the domain and the discarded medium, calls `destroy`, and opens again empty. Every other failure (`backend-not-found`, `facet-unsupported`, `already-open`, I/O errors) stays loud regardless of the declaration: misconfiguration and environmental faults are not medium damage. The retry is single-shot — a second failure propagates, so a persistently failing medium cannot loop.
|
||||
- With this in place the cache domain spec's version field gains its intended meaning: bumping `version` (or letting zod reject drifted rows) genuinely discards the whole medium and the cache rebuilds through its normal write points and cold reads — the recovery ladder's outermost rung, matching the row-level rungs already shipped.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
|
||||
|
||||
**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single.
|
||||
|
||||
**Cache-plugin-local recovery (catch damage errors in `SessionProjectionCache[Service.init]`, delete the file, reopen)** — rejected: the plugin cannot name the medium path without reaching around the backend abstraction, and every future derived domain would re-implement the same catch; the facility is the one place that already classifies open failures.
|
||||
|
||||
**Fall back to an ephemeral in-memory domain on damage** — rejected: it silently degrades to memory-only for the life of the process and the damaged file never heals; the next boot fails the same way.
|
||||
|
||||
**Rename the damaged medium aside (`<unit>.json.corrupt-<ts>`) instead of deleting** — not chosen: a derived medium's damaged bytes have no recovery value (the logs are the source of truth) and the litter accumulates unbounded; delete is the honest operation. Rename-aside remains the right choice if a future *authoritative* domain ever wants reset semantics — which is exactly why `recovery` is per-spec.
|
||||
|
||||
**A blanket auto-reset for every domain (no spec field)** — rejected outright: `workspace.json` is authoritative user data; silently resetting it on a version bump would destroy workspaces. Authority is a property of the domain and must be declared by its owner.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction.
|
||||
- With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed.
|
||||
- The same damage to `workspace.json` still fails boot loudly.
|
||||
- Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Auto-delete on a misclassified error destroys a healthy file.** Mitigated by the closed damage-class list: reset fires only on the three deterministic parse-time codes; ENOENT is already "empty unit", and every I/O error (EACCES, EIO) propagates loudly. The single-shot retry bounds the blast radius to one delete per open.
|
||||
- **Root relocation changes where existing checkouts look.** Accepted under the pre-release stance (backends reject old formats, no external consumers); the note above records the one-time manual move for anyone who cares about a per-cwd `workspace.json`'s content.
|
||||
- **`destroy` is a new destructive primitive on the storage seam.** Its only caller is the facility's declared-reset path; the backend contract documents it as facility-owned, and nothing model-facing or user-facing can reach it.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Agent Note:存储根目录落点与派生介质恢复
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-28-storage-root-and-derived-medium-recovery.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。
|
||||
|
||||
**文件到底存在哪。** 出厂组合给 json 后端的是相对根目录——`root: './.storages'`(apps/cli/cordis.yml)——而 `AppCLIEntry.composePatches` 只把会话存储的根 patch 到全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`,可经 profile 键 `persistenceRoot` 覆盖);`storage-json` 没有对应的 patch 也没有 profile 键。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
|
||||
|
||||
**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
|
||||
|
||||
## Proposal
|
||||
|
||||
两个独立改动,一个缺口一个。
|
||||
|
||||
### 全局唯一存储根,构造时 resolve 一次
|
||||
|
||||
- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →(`storage-json`,`root`),与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同。
|
||||
- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
|
||||
- 适用 pre-release 立场:不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。
|
||||
|
||||
### 声明派生介质:损坏时重置而非拒绝
|
||||
|
||||
- `DomainSpec` 增加 `recovery?: 'reject' | 'reset'`(默认 `'reject'`)。spec 对象已经是一个域的身份与布局的单一来源;其介质是权威还是派生属于同类事实,落在同一处。`session_projcache` 声明 `'reset'`;`workspace` 保持默认。
|
||||
- `KvFacet` 增加一个原语:`destroy(descriptor): Promise<void>`——整体移除该 unit 的介质(json:删文件;sqlite:drop 该 unit 的表)。与 `open` 一样,它是后端存储原语,不是策略。
|
||||
- `DomainFacility.open` 在 spec 声明 `'reset'` 且 open 恰以损坏类错误失败时——`StorageError('version-mismatch' | 'malformed-medium')` 或 `DomainError('invalid-record')`——记一条命名该域和被丢弃介质的警告,调用 `destroy`,再空开一次。其余一切失败(`backend-not-found`、`facet-unsupported`、`already-open`、I/O 错误)无论声明与否都保持大声:配置错误和环境故障不是介质损坏。重试单发——第二次失败原样传播,持续失败的介质不会成环。
|
||||
- 有了这个,缓存域 spec 的 version 字段才获得其本意:bump `version`(或让 zod 拒绝漂移行)真正丢弃整个介质,缓存经正常写点和冷读重建——恢复阶梯的最外一档,与已落地的行级各档对齐。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
|
||||
|
||||
**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。
|
||||
|
||||
**缓存插件本地恢复(在 `SessionProjectionCache[Service.init]` 捕获损坏错误、删文件、重开)**——拒绝:插件不越过后端抽象就叫不出介质路径,且未来每个派生域都要重抄同一段 catch;facility 是唯一已经在分类 open 失败的地方。
|
||||
|
||||
**损坏时退到内存态临时域**——拒绝:进程余生静默降级为仅内存,损坏文件永不自愈;下次启动照样失败。
|
||||
|
||||
**把损坏介质改名旁置(`<unit>.json.corrupt-<ts>`)而非删除**——未选:派生介质的损坏字节没有恢复价值(日志才是真源),残骸无界累积;删除才是诚实的操作。若未来某个*权威*域想要重置语义,旁置改名才是对的——这正是 `recovery` 按 spec 声明的理由。
|
||||
|
||||
**所有域一律自动重置(不加 spec 字段)**——断然拒绝:`workspace.json` 是权威用户数据;版本 bump 时静默重置会毁掉工作区。权威性是域的属性,必须由其所有者声明。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`);profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。
|
||||
- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。
|
||||
- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。
|
||||
- facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。
|
||||
|
||||
## Risks
|
||||
|
||||
- **错误分类失误导致自动删除健康文件。** 由封闭的损坏类清单缓解:重置只在三个确定性解析期代码上触发;ENOENT 本来就是「空 unit」,一切 I/O 错误(EACCES、EIO)大声传播。单发重试把爆炸半径限定为每次 open 至多一删。
|
||||
- **根迁移改变既有 checkout 的查找位置。** 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费者);上文为在乎 per-cwd `workspace.json` 内容的人记录了一次性手动搬移。
|
||||
- **`destroy` 是存储 seam 上新增的破坏性原语。** 唯一调用方是 facility 的声明重置路径;后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。
|
||||
@@ -59,6 +59,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -126,6 +128,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -204,6 +208,8 @@ jobs:
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -267,6 +273,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -318,6 +326,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -418,6 +428,8 @@ jobs:
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -495,6 +507,8 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -528,6 +542,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -559,6 +575,8 @@ jobs:
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -651,6 +669,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
# The Windows lanes deliberately skip the store cache like the required
|
||||
# windows job; an empty cache input disables setup-node's caching.
|
||||
@@ -740,6 +760,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
# Unlike the larger-runner suite, both platforms cache the store here:
|
||||
# the consolidated topology measures cache mechanics as workload.
|
||||
|
||||
@@ -90,7 +90,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`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)).
|
||||
- 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).
|
||||
- **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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9
|
||||
README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: 13a80b1d0e0105bc0c30c019209b2e0295b7bef9
|
||||
README.zh.md: 2a5d9c15c57351ef03ebe60a5cdf90f0d0c8f18b
|
||||
@@ -26,4 +26,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.
|
||||
|
||||
`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.
|
||||
@@ -26,4 +26,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` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
|
||||
|
||||
`pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。
|
||||
@@ -19,6 +19,13 @@
|
||||
- id: session
|
||||
name: '@deepseek-ai/dsh-session'
|
||||
|
||||
# Projection registry: drives every registered domain unit over committed
|
||||
# session events and serves finished values (history-tail projections block +
|
||||
# session/projection frames). Without this row every domain's optional unit
|
||||
# injection stays silent — no block, no frames, no titles/todos on the web.
|
||||
- id: session-projection
|
||||
name: '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
- id: session-title
|
||||
name: '@deepseek-ai/dsh-session-title'
|
||||
config:
|
||||
@@ -109,6 +116,16 @@
|
||||
- id: workspace
|
||||
name: '@deepseek-ai/dsh-workspace'
|
||||
|
||||
# Persisted projection cache: durable per-session checkpoints of every
|
||||
# registered projection unit (json backend → ./.storages/session_projcache.json,
|
||||
# beside workspace.json), throttled between the two mandatory points
|
||||
# (turn/end + detach), serving cold listings without full-log loads.
|
||||
- id: session-projection-cache
|
||||
name: '@deepseek-ai/dsh-session-projection-cache'
|
||||
config:
|
||||
writeEveryEvents: 200
|
||||
writeIntervalMs: 5000
|
||||
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
@@ -158,6 +175,18 @@
|
||||
- id: commands
|
||||
name: '@deepseek-ai/dsh-commands'
|
||||
|
||||
# Goal service + automatic same-session continuation + the /goal command.
|
||||
# The GoalService registers the 'goal' session projection unit; the web
|
||||
# GoalBar reads it through useProjection.
|
||||
- id: goal
|
||||
name: '@deepseek-ai/dsh-goal'
|
||||
|
||||
- id: goal-session
|
||||
name: '@deepseek-ai/dsh-goal-session'
|
||||
|
||||
- id: command-goal
|
||||
name: '@deepseek-ai/dsh-command-goal'
|
||||
|
||||
# Plan mode registers /plan (the first real command on the web surface).
|
||||
# Section text mirrors examples/tui-agent/cordis.yml (the reference
|
||||
# deployment); plan-mode throws at load on an empty section.
|
||||
@@ -309,10 +338,18 @@
|
||||
- id: ui-subagent
|
||||
name: '@deepseek-ai/dsh-client-ui-subagent'
|
||||
|
||||
# Goal surface: GoalBar in the input dock over the goal session projection.
|
||||
- id: ui-goal
|
||||
name: '@deepseek-ai/dsh-client-ui-goal'
|
||||
|
||||
# Model selection: the /model popupSelect + composer seat over session.models.
|
||||
- id: ui-model
|
||||
name: '@deepseek-ai/dsh-client-ui-model'
|
||||
|
||||
# Plan control: the composer plan seat over the plan projection + /plan channel.
|
||||
- id: ui-plan
|
||||
name: '@deepseek-ai/dsh-client-ui-plan'
|
||||
|
||||
- id: ui-question
|
||||
name: '@deepseek-ai/dsh-client-ui-question'
|
||||
|
||||
|
||||
@@ -28,9 +28,11 @@
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
|
||||
@@ -42,11 +44,14 @@
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-frontend": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
@@ -54,9 +59,10 @@
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
@@ -69,6 +75,7 @@
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
|
||||
|
||||
@@ -53,7 +53,7 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
continue
|
||||
}
|
||||
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
|
||||
const joined = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
if (joined !== '') text = joined
|
||||
}
|
||||
if (event.type === 'turn/end' && event.data.turn === targetTurn) {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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,180 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -62,6 +62,9 @@
|
||||
{
|
||||
"path": "../../packages/client/ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-plan"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-trajectory"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 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, details-panel resolution of a sub-callId, and
|
||||
// the expanded program body, inert bash / file-link sub-row gestures, and
|
||||
// the trajectory/waterfall tabs' sub-call cells and timing lanes.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
@@ -152,7 +152,7 @@ it('renders the fixture run_code turn: code parent row, nested sub-rows, error s
|
||||
`)
|
||||
})
|
||||
|
||||
it('expands the code row into the program body and resolves a sub-row through the details panel', async () => {
|
||||
it('expands the code row into the program body; sub-row clicks do not open details', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
@@ -171,26 +171,28 @@ it('expands the code row into the program body and resolves a sub-row through th
|
||||
}
|
||||
})
|
||||
|
||||
// Sub-row click → details panel resolves the sub-callId with FULL output.
|
||||
// Tool rows no longer drive the details panel: bash is inert, file paths
|
||||
// are host-open links (fixture openPath is a no-op success).
|
||||
const nest = document.querySelector('[data-subcalls]')
|
||||
if (nest === null) throw new Error('sub-call nest missing')
|
||||
const bashRow = nest.querySelector('[data-sample="bash-global"]')
|
||||
if (bashRow === null) throw new Error('bash sample sub-row missing')
|
||||
const fileLink = nest.querySelector('button')
|
||||
if (fileLink === null) throw new Error('file-path summary link missing on a read sub-row')
|
||||
const frame = document.querySelector('[data-details-collapsed]')
|
||||
if (frame === null) throw new Error('app frame missing')
|
||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
fireEvent.click(bashRow)
|
||||
const details = await screen.findByText('Input')
|
||||
const panel = details.closest('[class*="root"]')
|
||||
if (panel === null) throw new Error('details panel missing')
|
||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
fireEvent.click(fileLink)
|
||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
expect({
|
||||
title: visibleText(within(panel as HTMLElement).getByText('bash')),
|
||||
inputEchoesArgs: visibleText(panel).includes('ls notes'),
|
||||
outputComplete: visibleText(panel).includes('demo.txt new-demo.txt')
|
||||
|| visibleText(panel).includes('demo.txt\nnew-demo.txt')
|
||||
|| (panel.textContent ?? '').includes('demo.txt\nnew-demo.txt'),
|
||||
fileLink: visibleText(fileLink),
|
||||
detailsCollapsed: frame.getAttribute('data-details-collapsed'),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"inputEchoesArgs": true,
|
||||
"outputComplete": true,
|
||||
"title": "bash",
|
||||
"detailsCollapsed": "true",
|
||||
"fileLink": "notes/demo.txt",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -118,19 +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 sub-row click opens the details panel on the sub-call material', async () => {
|
||||
it.skipIf(MODE === 'record')('a bash sub-row click leaves the details panel collapsed', 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()
|
||||
await nest.locator('[data-sample="bash-global"]').first().click()
|
||||
// The details column opens (width > 0) and shows the sub-call's complete
|
||||
// output — the full-content log contract, no truncation marker anywhere.
|
||||
await page.waitForFunction(() => {
|
||||
const frame = document.querySelector('[class*="frame"]')
|
||||
if (frame === null) return false
|
||||
return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0
|
||||
}, undefined, { timeout: 10_000 })
|
||||
await expect.poll(() => page.getByText('CODE_ROUND_OK', { exact: false }).count(), { timeout: 5_000 })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
// Tool rows no longer open details; the column stays width 0.
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
|
||||
|
||||
@@ -42,10 +42,10 @@ function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
|
||||
const callIds = new Set(calls.map(event => String(event.data.callId)))
|
||||
const results = events.filter(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
|
||||
event.type === 'tool/result' && callIds.has(String(event.data.callId)),
|
||||
event.type === 'tool/result' && callIds.has(String(event.data.message.source.callId)),
|
||||
)
|
||||
expect(results).toHaveLength(CORDIS_TOOLS.length)
|
||||
expect(results.every(event => !event.data.isError)).toBe(true)
|
||||
expect(results.every(event => !event.data.message.content[0].isError)).toBe(true)
|
||||
}
|
||||
|
||||
describe('web e2e: Cordis tools use the generic row variants', () => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// Web e2e scenarios: navigation & panes — the view tabs (Trajectory /
|
||||
// Waterfall), the details column, 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 and a details-capable bash row), turn 2 a
|
||||
// markdown-rich reply (a second turn so the waterfall has two lanes).
|
||||
// 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).
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -25,7 +24,6 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', impor
|
||||
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 DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'navigation-panes-web-e2e'
|
||||
|
||||
@@ -155,36 +153,27 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {
|
||||
it.skipIf(MODE === 'record')('bash and file-path rows leave the details column collapsed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
|
||||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||||
// The bash toolview row routes its click to openDetails (read rows are
|
||||
// expand-in-place instead — the seeded-history scenario owns that fold).
|
||||
const bashRow = page.locator('[data-sample="bash-global"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
// Open/closed is the frame's collapsed attribute: the column collapses to
|
||||
// width 0 but its subtree deliberately never unmounts (hidden, not
|
||||
// absent), so element presence/visibility cannot express the state.
|
||||
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
|
||||
await bashRow.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull()
|
||||
// The open panel shows the selected call's name, arguments, and durable
|
||||
// result (NAVIGATION_OK appears in the chat row too, hence >= 2 total).
|
||||
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
// Golden of the open panel: tool name header, Input args, Output result.
|
||||
const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('button', { name: '关闭详情' }).click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.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()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md',
|
||||
'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -87,10 +87,10 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
const bashCall = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'bash')
|
||||
if (bashCall?.type !== 'tool/call') throw new Error('the replayed turn did not call the bash tool')
|
||||
const bashResult = sessionEvents.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === bashCall.data.callId)
|
||||
event.type === 'tool/result' && event.data.message.source.callId === bashCall.data.callId)
|
||||
if (bashResult?.type !== 'tool/result') throw new Error('the bash tool call produced no durable result')
|
||||
expect(bashResult.data.isError).toBe(false)
|
||||
expect(bashResult.data.content.filter(block => block.type === 'text').map(block => block.text).join(''))
|
||||
expect(bashResult.data.message.content[0].isError).toBe(false)
|
||||
expect(bashResult.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text).join(''))
|
||||
.toBe('WEB_E2E_OK\n')
|
||||
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
|
||||
expect(turnEnds.length).toBe(1)
|
||||
|
||||
@@ -71,6 +71,35 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await recordFixture(scaffold, sessionId, SEED)
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('serves the projections baseline on the real composition tail page', async () => {
|
||||
// Composition regression tripwire: the projection registry must be a row
|
||||
// in the SHIPPED cordis.yml — with it absent every domain unit's optional
|
||||
// injection stays silent and this block disappears (no titles/todos on
|
||||
// the web), while fixture-level suites stay green. Assert through the
|
||||
// real HTTP wire against the booted real host.
|
||||
const response = await fetch(`${scaffold.baseUrl}/api/session.history`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request', rpcId: 'seeded-projections', method: 'session.history',
|
||||
payload: { sessionId: SEED_ID },
|
||||
}),
|
||||
})
|
||||
expect(response.ok).toBe(true)
|
||||
const body = await response.json() as {
|
||||
result: { ok: boolean; value?: { projections?: { asOfSeq: number; values: Record<string, unknown> } } }
|
||||
}
|
||||
expect(body.result.ok).toBe(true)
|
||||
const projections = body.result.value?.projections
|
||||
expect(projections).toBeDefined()
|
||||
expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0)
|
||||
// The seed carries a session/title event: the title unit must serve it.
|
||||
expect(typeof projections?.values.title).toBe('string')
|
||||
// tool-todo is composed but the seed has no todo/write: whole-value null,
|
||||
// key PRESENT (absence would mean the unit never registered).
|
||||
expect(projections?.values).toHaveProperty('todos', null)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-history'))
|
||||
// The sidebar tree collapses workspace groups by default: click the group
|
||||
@@ -103,21 +132,19 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('expands and collapses a tool row rebuilt from the cold log', async () => {
|
||||
it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow'))
|
||||
// Interaction over cold-resumed history: read rows are expand-in-place
|
||||
// rows (rowExpands routes the click to toggleExpand, not openDetails), so
|
||||
// the gesture under test is the inline fold over log-rebuilt content.
|
||||
// Runs after the golden capture; still zero model calls.
|
||||
const row = page.locator('[data-variant] [data-clickable][role="button"]').first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
expect(await row.getAttribute('aria-expanded')).toBe('false')
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
|
||||
// The expanded body renders the recorded tool result (a.txt's contents).
|
||||
await expect.poll(() => page.getByText('alpha', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
|
||||
// Interaction over cold-resumed history: read summaries are host-open
|
||||
// file links (not expand-in-place / not details). Runs after the golden
|
||||
// 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()
|
||||
await fileLink.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.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)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
// Web e2e scenario: the sidebar session list's scrollbar as the browser
|
||||
// actually lays it out — the observable half of the themed-scrollbar change
|
||||
// (packages/client/ui-theme/src/styles/scrollbar.css plus the
|
||||
// `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The
|
||||
// ui-theme/ui-workspace unit specs read the CSS text; only a real engine
|
||||
// reports the reserved gutter width and the substituted `scrollbar-color`, so
|
||||
// those two facts live here.
|
||||
//
|
||||
// Zero model calls: the list only has to overflow, so the scenario seeds many
|
||||
// cold sessions from another spec's committed fixture (seeded-history's
|
||||
// seed.jsonl, reused read-only — this spec needs row count, not new recorded
|
||||
// content) and never launches a replay row. A stray stream would fail loud
|
||||
// with NO_ADAPTER.
|
||||
//
|
||||
// Headless-chromium caveats, load-bearing for what is asserted below.
|
||||
//
|
||||
// Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the
|
||||
// content, consuming no layout width unless something reserves space. That is
|
||||
// the mode in which the reported symptom exists at all, so this environment
|
||||
// reproduces it rather than merely approximating it — measured against clean
|
||||
// master, where the list's band is 0 and the bar covers 7px of the relative
|
||||
// time. (Under a classic space-consuming bar, `clientWidth` already excludes
|
||||
// the bar and nothing can be covered; a headed run under xvfb behaves that way
|
||||
// and cannot show the symptom.)
|
||||
//
|
||||
// The consequence for assertions: comparing the time element's right edge
|
||||
// against the list's CLIENT-area right edge holds in both states and proves
|
||||
// nothing, because with an overlay bar the client edge is the border edge. The
|
||||
// two signals that do separate the states are the reserved band width and
|
||||
// `timeCoveredBy`, which measures the overlap against the bar's own width.
|
||||
//
|
||||
// Both the `scrollbar-gutter: stable` reservation and the sheet's
|
||||
// `::-webkit-scrollbar` width are needed for that band, and neither suffices:
|
||||
// measured on the running app, deleting either one takes the band from 8 to 0
|
||||
// while the other stays in force. The gutter states that space be reserved; the
|
||||
// pseudo-element width is what makes chromium treat the bar as occupying layout
|
||||
// space in the first place.
|
||||
//
|
||||
// That conjunction is why `band` and `timeCoveredBy` are both asserted and
|
||||
// neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at
|
||||
// 0, because the bar is then 8px wide and the row's right padding is also 8px,
|
||||
// so it abuts the timestamp without covering it; `band` catches that case.
|
||||
// Removing both — the actual master state — is what produces the reported
|
||||
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
|
||||
// the other assertions in its test silenced.
|
||||
//
|
||||
// Chromium also takes the `::-webkit-scrollbar*` path, not the standard
|
||||
// properties: scrollbar.css gates `scrollbar-width`/`scrollbar-color` behind
|
||||
// `@supports not selector(::-webkit-scrollbar)`, which is false here. The
|
||||
// resolved standard properties therefore read `auto`, and that reading is
|
||||
// asserted — a concrete value would mean the gate leaked and silenced the
|
||||
// pseudo-element rules. What the theme test measures instead is the pair the
|
||||
// pseudo-element rules read: the indirection variables as they resolve ON the
|
||||
// list, plus the `::-webkit-scrollbar-thumb:hover` declaration as it stands in
|
||||
// the cascade. The hover thumb colour is not observable any other way —
|
||||
// chromium folds the `:hover` rule into `getComputedStyle(el,
|
||||
// '::-webkit-scrollbar-thumb')`, so that query reports the hover colour at
|
||||
// rest and cannot pin either state (measured by deleting the hover rule live:
|
||||
// the same query flipped from the hover colour to the resting one).
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
|
||||
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url))
|
||||
/**
|
||||
* Committed golden of the resolved scrollbar style and geometry, in both
|
||||
* palettes. The aria goldens the other scenarios commit cannot carry this
|
||||
* change: it alters no DOM and no accessible name, so their normalized trees are
|
||||
* byte-identical with and without it. This one records the values instead, which
|
||||
* makes an unintended shift in thumb colour, band width, or rendering path a
|
||||
* reviewable diff rather than an assertion someone has to think about.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
/** Enough rows that the list overflows the 800px-tall viewport's sidebar; the scenario asserts the overflow rather than trusting it. */
|
||||
const SEED_COUNT = 24
|
||||
|
||||
/** Geometry and resolved scrollbar style of one scroll container, measured in the page. */
|
||||
interface ListMetrics {
|
||||
/** Resolved `scrollbar-gutter`. */
|
||||
gutter: string
|
||||
/** Resolved `::-webkit-scrollbar` width: the pseudo-element path's own sizing. */
|
||||
width: string
|
||||
/** Resolved `::-webkit-scrollbar-track` background. */
|
||||
track: string
|
||||
/** Resolved `scrollbar-width`, expected `auto` because the gate excludes chromium. */
|
||||
standardWidth: string
|
||||
/** Resolved `scrollbar-color`, expected `auto` for the same reason. */
|
||||
standardColor: string
|
||||
/** `::-webkit-scrollbar-thumb:hover` background declarations found in the cascade, in sheet order. */
|
||||
hoverRules: string[]
|
||||
/** `--dsh-scrollbar-thumb` resolved on the list, serialized as a colour. */
|
||||
token: string
|
||||
/** `--dsh-scrollbar-thumb-hover` resolved on the list, serialized the same way. */
|
||||
hoverToken: string
|
||||
/** True when the list actually scrolls. */
|
||||
overflows: boolean
|
||||
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
|
||||
band: number
|
||||
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
|
||||
clientRight: number
|
||||
/** Border-box right edge in viewport coordinates. */
|
||||
borderRight: number
|
||||
/** Right edge of the first row's relative-time element, the content the unreserved bar covered. */
|
||||
timeRight: number
|
||||
/**
|
||||
* Pixels of the relative time the scrollbar paints over: how far its right
|
||||
* edge reaches into the band the bar occupies, `[borderRight - barWidth,
|
||||
* borderRight]`. This is the reported symptom as a number, and it is the one
|
||||
* geometric signal that separates the two states in this environment — see
|
||||
* the file header on why `clientWidth` comparisons cannot.
|
||||
*/
|
||||
timeCoveredBy: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the sidebar list in the page.
|
||||
* @param page - the page under test.
|
||||
* @returns the list's resolved scrollbar style and the geometry the fix changes.
|
||||
*/
|
||||
function measureList(page: Page): Promise<ListMetrics> {
|
||||
return page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
|
||||
if (list === null) throw new Error('sidebar session list not in the DOM')
|
||||
const time = list.querySelector<HTMLElement>('[class*="time"]')
|
||||
if (time === null) throw new Error('no row relative-time element in the sidebar list')
|
||||
// Each indirection variable is resolved through its own throwaway probe
|
||||
// appended to the list: `var()` substitution then happens where the list
|
||||
// sits in the cascade, which is the claim, and `color` normalizes whatever
|
||||
// notation the palette sheet chose into one comparable serialization. A
|
||||
// REUSED probe would report only the last value read — `getComputedStyle`
|
||||
// returns a live declaration, so reassigning `style.color` retroactively
|
||||
// changes every earlier read.
|
||||
const resolve = (name: string): string => {
|
||||
const probe = document.createElement('span')
|
||||
probe.style.color = `var(${name})`
|
||||
list.append(probe)
|
||||
const value = getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
return value
|
||||
}
|
||||
// The hover colour is read out of the cascade rather than computed:
|
||||
// chromium reports the `:hover` background for the resting pseudo-element
|
||||
// too (see the file header), so no computed query separates the states.
|
||||
// Cross-origin sheets throw on `cssRules`; none is expected, and skipping
|
||||
// them cannot mask the rule under test, which ships in the app's own CSS.
|
||||
const hoverRules = [...document.styleSheets]
|
||||
.flatMap((sheet) => {
|
||||
try {
|
||||
return [...sheet.cssRules]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
.filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
|
||||
.filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
|
||||
.map(rule => rule.style.getPropertyValue('background'))
|
||||
const style = getComputedStyle(list)
|
||||
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
|
||||
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
|
||||
return {
|
||||
gutter: style.scrollbarGutter,
|
||||
width: pseudoWidth,
|
||||
track: getComputedStyle(list, '::-webkit-scrollbar-track').backgroundColor,
|
||||
standardWidth: style.scrollbarWidth,
|
||||
standardColor: style.scrollbarColor,
|
||||
hoverRules,
|
||||
token: resolve('--dsh-scrollbar-thumb'),
|
||||
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
|
||||
overflows: list.scrollHeight > list.clientHeight,
|
||||
band: list.getBoundingClientRect().width - list.clientWidth,
|
||||
clientRight: list.getBoundingClientRect().left + list.clientWidth,
|
||||
borderRight: list.getBoundingClientRect().right,
|
||||
timeRight: time.getBoundingClientRect().right,
|
||||
// The bar is drawn in the rightmost `barWidth` of the border box, whether
|
||||
// or not that space was reserved. Its width comes from the sheet where the
|
||||
// sheet applies, and from the UA's own overlay bar otherwise — 15px is
|
||||
// what this chromium paints, measured against master where the rule is
|
||||
// absent. Taking the UA width as the fallback is what keeps the assertion
|
||||
// honest: assuming 0 there would report no occlusion precisely in the
|
||||
// state that has it.
|
||||
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the golden body: the resolved scrollbar style of the list in each
|
||||
* palette, plus the geometric relations the fix establishes.
|
||||
*
|
||||
* Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
|
||||
* `borderRight` depend on the sidebar's laid-out width and on font metrics, so
|
||||
* committing them would make the golden fail on a machine whose fonts measure
|
||||
* differently — a fixture that has to be re-recorded per platform documents the
|
||||
* platform, not the change. What is recorded instead is the band, the overlap,
|
||||
* and the two orderings, each of which is a difference or a comparison and so
|
||||
* survives any layout that keeps the reservation.
|
||||
* @param light - metrics measured under the light palette.
|
||||
* @param dark - metrics measured under the dark palette.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
|
||||
const palette = (name: string, metrics: ListMetrics): string[] => [
|
||||
`## ${name}`,
|
||||
'',
|
||||
`- scrollbar-gutter: ${metrics.gutter}`,
|
||||
`- ::-webkit-scrollbar width: ${metrics.width}`,
|
||||
`- ::-webkit-scrollbar-track background: ${metrics.track}`,
|
||||
`- scrollbar-width: ${metrics.standardWidth}`,
|
||||
`- scrollbar-color: ${metrics.standardColor}`,
|
||||
`- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`,
|
||||
`- --dsh-scrollbar-thumb: ${metrics.token}`,
|
||||
`- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`,
|
||||
`- list overflows: ${String(metrics.overflows)}`,
|
||||
`- reserved band: ${String(metrics.band)}px`,
|
||||
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
|
||||
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
|
||||
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
|
||||
'',
|
||||
]
|
||||
return [
|
||||
'# Sidebar session list scrollbar',
|
||||
'',
|
||||
...palette('Light palette', light),
|
||||
...palette('Dark palette', dark),
|
||||
].join('\n').trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the seeded rows: every seeded session is unattached, so they all sit
|
||||
* in the collapsed Ungrouped bucket. Converges on expanded rather than
|
||||
* clicking once — startup auto-selection can expand the bucket first, and a
|
||||
* second click would collapse it again. Hand-rolled polling because
|
||||
* `expect.poll` is test-scoped and this runs in `beforeAll`.
|
||||
* @param page - the page under test.
|
||||
*/
|
||||
async function expandSeededSessions(page: Page): Promise<void> {
|
||||
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
await bucket.waitFor({ timeout: 15_000 })
|
||||
const rows = page.locator('[role="tree"][aria-label="Sessions"] [role="treeitem"]')
|
||||
const deadline = Date.now() + 30_000
|
||||
for (;;) {
|
||||
if (await bucket.getAttribute('aria-expanded') !== 'true') {
|
||||
await page.getByText('Ungrouped', { exact: true }).click()
|
||||
}
|
||||
if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`)
|
||||
}
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thumb)', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
const fixture = await readFile(SEED, 'utf8')
|
||||
for (let index = 0; index < SEED_COUNT; index += 1) {
|
||||
await seedSession(scaffold, fixture, `sidebar-scrollbar-web-e2e-${String(index).padStart(2, '0')}`)
|
||||
}
|
||||
browser = await chromium.launch()
|
||||
// Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow
|
||||
// the list with room to spare.
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 800 } })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await expandSeededSessions(page)
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('reserves a scrollbar gutter on the overflowing session list', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-gutter'))
|
||||
// Vacuity guard: with a non-overflowing list `stable` still reserves, but
|
||||
// the scenario would no longer be reproducing the reported situation.
|
||||
await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true)
|
||||
const metrics = await measureList(page)
|
||||
expect(metrics.gutter).toBe('stable')
|
||||
// The control. `band > 0` is the whole observable effect of the
|
||||
// reservation: the scrollbar is taken out of the content area instead of
|
||||
// drawn over it. Removing the declaration makes it exactly 0. The value
|
||||
// itself is not pinned — it tracks `scrollbar-width` and the platform.
|
||||
expect(metrics.band).toBeGreaterThan(0)
|
||||
// The reported symptom, stated directly: no part of the row's relative time
|
||||
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
|
||||
// covered part. Unlike the client-edge comparison below it does not go
|
||||
// vacuous under an overlay scrollbar, because it measures against the bar's
|
||||
// own width rather than against a content edge the overlay bar does not
|
||||
// move. It is not a replacement for the band assertion above; see the file
|
||||
// header for which regression each one catches.
|
||||
expect(metrics.timeCoveredBy).toBe(0)
|
||||
// Corollaries of the reservation, kept because they pin where the band sits
|
||||
// rather than only that it exists: the time ends inside the content area,
|
||||
// and the content area ends before the border box. Each holds in both
|
||||
// states on its own (see the file header) and is meaningful only alongside
|
||||
// the two assertions above.
|
||||
expect(metrics.timeRight).toBeLessThanOrEqual(metrics.clientRight)
|
||||
expect(metrics.clientRight).toBeLessThan(metrics.borderRight)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('renders the themed thumb through the WebKit path in both palettes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
|
||||
const light = await measureList(page)
|
||||
// The gate's signature on this engine, and the reason it exists: chromium
|
||||
// implements `::-webkit-scrollbar`, so the standard properties stay at
|
||||
// their initial `auto`. A concrete value here would mean the gate leaked,
|
||||
// which is exactly what makes chromium discard the pseudo-element rules —
|
||||
// the hover token included.
|
||||
expect(light.standardWidth).toBe('auto')
|
||||
expect(light.standardColor).toBe('auto')
|
||||
// The pseudo-element path is the one in force: the sheet's own 8px sizing
|
||||
// and transparent track reached a container it never names.
|
||||
expect(light.width).toBe('8px')
|
||||
expect(light.track).toBe('rgba(0, 0, 0, 0)')
|
||||
// The resting and the hover rule each read the rebindable indirection, and
|
||||
// the two resolve to DIFFERENT colours on this list: the l1 pair arrived
|
||||
// here intact rather than collapsing to one value or falling back.
|
||||
expect(light.hoverRules).toEqual(['var(--dsh-scrollbar-thumb-hover)'])
|
||||
expect(light.token).toMatch(/^rgba?\(/)
|
||||
expect(light.hoverToken).not.toBe(light.token)
|
||||
// The dark palette declares different scrollbar tokens; driving the body
|
||||
// attribute pins the cascade the way lifecycle-chrome does (the Settings
|
||||
// gesture that sets it is owned there).
|
||||
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
|
||||
const dark = await measureList(page)
|
||||
expect(dark.token).not.toBe(light.token)
|
||||
expect(dark.hoverToken).not.toBe(dark.token)
|
||||
expect(dark.hoverToken).not.toBe(light.hoverToken)
|
||||
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
|
||||
const restored = await measureList(page)
|
||||
expect(restored.token).toBe(light.token)
|
||||
expect(restored.hoverToken).toBe(light.hoverToken)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('matches the committed scrollbar geometry golden in both palettes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden'))
|
||||
const light = await measureList(page)
|
||||
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
|
||||
const dark = await measureList(page)
|
||||
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('commits exactly the fixtures it reads', async () => {
|
||||
// The scenario borrows seeded-history's seed.jsonl rather than committing a
|
||||
// second copy, so this directory holds the golden alone.
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@
|
||||
//
|
||||
// Selector convention: CSS Modules hash as [hash]_[local], so class-substring
|
||||
// selectors are unreliable — anchor on data-* attributes (data-variant /
|
||||
// data-clickable / data-sample) or visible text. The one [class*=] use below
|
||||
// data-sample) or visible text. The one [class*=] use below
|
||||
// (frame/handle) rides local names that survive hashing as suffixes; prefer
|
||||
// data-* for anything new.
|
||||
//
|
||||
@@ -89,8 +89,10 @@ function providerTitle(page: HistoryPage): string | undefined {
|
||||
|
||||
function hasAssistantMarker(page: HistoryPage, marker: string): boolean {
|
||||
return page.events.some(({ event }) => {
|
||||
if (event.type !== 'assistant/message' || !isRecord(event.data) || !Array.isArray(event.data.content)) return false
|
||||
return event.data.content.some(block =>
|
||||
if (event.type !== 'assistant/message' || !isRecord(event.data) || !isRecord(event.data.message)) return false
|
||||
const content = event.data.message.content
|
||||
if (!Array.isArray(content)) return false
|
||||
return content.some(block =>
|
||||
isRecord(block) && block.type === 'text' && typeof block.text === 'string' && block.text.includes(marker))
|
||||
})
|
||||
}
|
||||
@@ -448,7 +450,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '07-back-to-chat')
|
||||
})
|
||||
|
||||
it('5 bash differential rendering: tool row click opens the details column', async () => {
|
||||
it('5 bash differential rendering: tool row click leaves the details column collapsed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
|
||||
@@ -462,13 +464,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '08-bash-round')
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await toolRow.click()
|
||||
// Selection channel: click writes selection + layout.openDetails.
|
||||
await page.waitForFunction(() => {
|
||||
const frame = document.querySelector('[class*="frame"]')
|
||||
if (frame === null) return false
|
||||
return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0
|
||||
}, undefined, { timeout: 10_000 })
|
||||
await screen(page, '09-details-open')
|
||||
// Tool rows no longer drive layout.openDetails; the column stays closed.
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await screen(page, '09-details-closed')
|
||||
}, 150_000)
|
||||
|
||||
it('6 sidebar drag widens the column and persists across reload', async () => {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
- text: bash
|
||||
- button "关闭详情"
|
||||
- text: Input
|
||||
- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }"
|
||||
- text: Output NAVIGATION_OK
|
||||
@@ -0,0 +1,33 @@
|
||||
# Sidebar session list scrollbar
|
||||
|
||||
## Light palette
|
||||
|
||||
- scrollbar-gutter: stable
|
||||
- ::-webkit-scrollbar width: 8px
|
||||
- ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0)
|
||||
- scrollbar-width: auto
|
||||
- scrollbar-color: auto
|
||||
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
|
||||
- --dsh-scrollbar-thumb: rgb(229, 229, 229)
|
||||
- --dsh-scrollbar-thumb-hover: rgb(212, 212, 212)
|
||||
- list overflows: true
|
||||
- reserved band: 8px
|
||||
- relative time covered by the bar: 0px
|
||||
- relative time ends inside the content area: true
|
||||
- content area ends before the border box: true
|
||||
|
||||
## Dark palette
|
||||
|
||||
- scrollbar-gutter: stable
|
||||
- ::-webkit-scrollbar width: 8px
|
||||
- ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0)
|
||||
- scrollbar-width: auto
|
||||
- scrollbar-color: auto
|
||||
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
|
||||
- --dsh-scrollbar-thumb: rgb(60, 60, 61)
|
||||
- --dsh-scrollbar-thumb-hover: rgb(84, 85, 87)
|
||||
- list overflows: true
|
||||
- reserved band: 8px
|
||||
- relative time covered by the bar: 0px
|
||||
- relative time ends inside the content area: true
|
||||
- content area ends before the border box: true
|
||||
@@ -4,8 +4,9 @@
|
||||
// Opens the fixture history session and pins the todo_write turn's two
|
||||
// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary
|
||||
// derived from the call args) and the TodoPanel plan strip riding the
|
||||
// 'conversation.input.dock' slot (fed by ConversationSnapshot.todos, seeded
|
||||
// by the tail history page), including the collapse interaction.
|
||||
// 'conversation.input.dock' slot (fed by the host `todos` projection via
|
||||
// useProjection, seeded by the tail history page), including the collapse
|
||||
// interaction and the next-turn clearance of the standing plan.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
@@ -189,3 +190,31 @@ it('collapses the plan strip to the count summary and restores it', async () =>
|
||||
fireEvent.click(header)
|
||||
expect(panel.querySelectorAll('li')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('hides the plan strip when the next turn starts', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
expect(document.querySelector('[data-testid="todo-panel"]')).not.toBeNull()
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Message the agent', {}, { timeout: 10_000 })
|
||||
fireEvent.change(composer, { target: { value: '下一轮清空计划' } })
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
|
||||
await screen.findByText('下一轮清空计划', { exact: true }, { timeout: 10_000 })
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-testid="todo-panel"]')).toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
expect({
|
||||
promptVisible: screen.getByText('下一轮清空计划', { exact: true }).textContent,
|
||||
panelGone: document.querySelector('[data-testid="todo-panel"]') === null,
|
||||
// Historical todo_write row stays in the flow; only the dock strip clears.
|
||||
rowStillPresent: document.querySelector('[data-sample="todo-row"]') !== null,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"panelGone": true,
|
||||
"promptVisible": "下一轮清空计划",
|
||||
"rowStillPresent": true,
|
||||
}
|
||||
`)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user