Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/subsystems/attachment.i18n.yaml # docs/subsystems/attachment.md # docs/subsystems/attachment.zh.md # docs/subsystems/core.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/README.md # packages/README.zh.md # packages/client/runtime/package.json # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/self-modification/tool-cordis/src/api-catalog.ts # pnpm-lock.yaml # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md
|
||||
2026-07-19-windows-atomic-write-dacl-preservation.md: be9f82174300a7d605c6a6e63878728f08cb37be
|
||||
2026-07-19-windows-atomic-write-dacl-preservation.zh.md: d31b52b0a99240a1e4449427438e1e187b1b98da
|
||||
2026-07-19-windows-atomic-write-dacl-preservation.zh.md: ed52773b0314e06ecdc470f9d569685c47a7aec3
|
||||
|
||||
@@ -10,9 +10,9 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新的 Windows 文件没有既有描述符需要保留,因此仍继承目标目录的 DACL;其暂存目录也因此位于目标文件旁。POSIX 继续使用仅所有者可访问的暂存 mode,并保留现有目标文件的 mode。
|
||||
`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定约定。新的 Windows 文件没有既有描述符需要保留,因此仍继承目标目录的 DACL;其暂存目录也因此位于目标文件旁。POSIX 继续使用仅所有者可访问的暂存 mode,并保留现有目标文件的 mode。
|
||||
|
||||
Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。mode 位断言仍仅适用于 POSIX;新文件的 DACL 继承由操作系统契约规定,不应通过特定机器的账户允许列表来断言。
|
||||
Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。mode 位断言仍仅适用于 POSIX;新文件的 DACL 继承由操作系统约定规定,不应通过特定机器的账户允许列表来断言。
|
||||
|
||||
## 备选方案
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md
|
||||
2026-07-20-config-hot-reload-resilience.md: 0f15bb0aaacb6e06c416cbe35b44155279497eee
|
||||
2026-07-20-config-hot-reload-resilience.zh.md: 8a185c1915b5247150d8bb1dd5c42d69bd4f2a35
|
||||
2026-07-20-config-hot-reload-resilience.md: 2b98ee73e1c7734da9c0ae6df5e3de1af6a5cbeb
|
||||
2026-07-20-config-hot-reload-resilience.zh.md: 0a3652a7cac589b601d7fd30d0c713d4585be830
|
||||
|
||||
@@ -38,4 +38,4 @@ HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` met
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer, and the ACP `pty-tools` snapshot guards concurrent composition from reordering equal-priority prompt sections.
|
||||
`packages/boot/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/boot/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer, and the ACP `pty-tools` snapshot guards concurrent composition from reordering equal-priority prompt sections.
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
无效的 `cordis.yml` 编辑不得杀死运行中的 agent(智能体);但若一次看似有效的更新先部分替换 Loader 树,后续配置项才失败,仅仅保住进程仍不够。调用方还需要能观察到被拒绝的实时更新,同时不能让同一个错误被当作未处理的启动失败。个人配置还带来第二项要求:HMR(热模块替换)必须观察其模块根目录之外的一个确切文件,包括启动后才创建的文件或父目录。
|
||||
无效的 `cordis.yml` 编辑不得杀死运行中的 agent(智能体);但若一次看似有效的更新先部分替换 Loader 树,后续配置项才失败,仅仅保住进程仍不够。调用方还需要能观察到被拒绝的实时更新,同时不能让同一个错误被当作未处理的启动失败。个人配置还带来第二项要求:HMR(热模块替换)必须观察其模块根目录之外的一个确切文件,即使该文件或其父目录在启动后才创建也不例外。
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -16,7 +16,7 @@ vendor 中的 Cordis 生命周期和 Loader 插件提供可等待、带补偿的
|
||||
|
||||
Loader 会先导入变化后的模块名,再 dispose(资源释放)活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect,并恢复先前的插件或配置。组内对账会并发启动各候选项,等待每项结果,并会在拒绝前恢复已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。
|
||||
|
||||
Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载继续快速失败;只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。
|
||||
Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载仍会明确报错;只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。
|
||||
|
||||
HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方法从最近的现有祖先目录开始监听一个确切路径,串行化并合并刷新,并返回一个异步 disposer;该 disposer 会关闭 watcher 并排空活跃工作。确切路径和普通配置文件的刷新都使用此队列。失败会被规范化为 `Error`、记入日志,并通过并行事件 `hmr/config-update-failed(filename, error)` 广播;发生 rejection 的观察者会被记录,但不会阻止后续刷新。创建、变更和移除均会被观察。
|
||||
|
||||
@@ -38,4 +38,4 @@ HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除;ACP(Agent Client Protocol)的 `pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序。
|
||||
`packages/boot/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/boot/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除;ACP(Agent Client Protocol)的 `pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md
|
||||
2026-07-20-error-cause-chain-diagnostics.md: 89edd4529cc16617fdca736dbbea4b170400d6bd
|
||||
2026-07-20-error-cause-chain-diagnostics.zh.md: 673921c913a9f1fefe6f85303cf5952f8edbcc1d
|
||||
2026-07-20-error-cause-chain-diagnostics.md: 32716b5a68b3b73bded47633eca95995cdbfc586
|
||||
2026-07-20-error-cause-chain-diagnostics.zh.md: 9911c32b6d68c1f5569a1fceadb65e23c6586594
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Render error cause chains at every diagnostic seam
|
||||
# Agent Note: Render error cause chains at every diagnostic boundary
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -8,14 +8,14 @@ English | [中文](2026-07-20-error-cause-chain-diagnostics.zh.md)
|
||||
|
||||
A TUI run against an unreachable DeepSeek endpoint failed with the single notice `fetch failed` and no further detail. Two independent gaps produced that dead end:
|
||||
|
||||
1. undici's `fetch` wraps every transport failure (DNS, refused connection, TLS, proxy) in a bare `TypeError: fetch failed` whose actionable detail — `ECONNREFUSED`, `bad port`, the Happy Eyeballs AggregateError — lives on `error.cause`. Every diagnostic seam in the harness rendered only `error.message` (or `String(error)`, which is equivalent for Errors), so the wrapper masked the diagnosis in the TUI notice, the durable `turn/end` reason, and every logger line.
|
||||
1. undici's `fetch` wraps every transport failure (DNS, refused connection, TLS, proxy) in a bare `TypeError: fetch failed` whose actionable detail — `ECONNREFUSED`, `bad port`, the Happy Eyeballs AggregateError — lives on `error.cause`. Every diagnostic boundary in the harness rendered only `error.message` (or `String(error)`, which is equivalent for Errors), so the wrapper masked the diagnosis in the TUI notice, the durable `turn/end` reason, and every logger line.
|
||||
2. The readline front door (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence.
|
||||
|
||||
## Decision
|
||||
|
||||
- `dsh-llm` exports `errorChain(value)`: renders a thrown value with its full `cause` chain (`outer: inner: …`) and AggregateError members (`msg [m1; m2]`), with circular-cause and hostile-coercion containment. It is a diagnostic-surface renderer only; routing stays on `HarnessError.code`.
|
||||
- The DeepSeek adapter wraps a pre-response transport failure in `LlmError('TRANSPORT')` naming the configured `baseURL` and chaining the original rejection as `cause`. An aborted request becomes `LlmError('ABORTED')`; because the turn signal is already aborted, the loop still classifies the turn as cancellation rather than recovery.
|
||||
- Every diagnostic seam renders through `errorChain` instead of `error.message`/`String(error)`: the agent-loop's durable `turn/end` error message (`errorData`), its logger warnings, the TUI's `agent/error` notice and startup-failure line, and `dsh-stdio`'s startup-failure log lines. The live `agent/error` event and `SettleReason` preserve the thrown value as `unknown`; each diagnostic consumer renders it instead of the loop wrapping it into another error. The per-package `renderThrown` copies in `dsh-agent-loop`, `dsh-stdio`, and `dsh-tui` are deleted in favor of the one shared renderer.
|
||||
- Every diagnostic boundary renders through `errorChain` instead of `error.message`/`String(error)`: the agent-loop's durable `turn/end` error message (`errorData`), its logger warnings, the TUI's `agent/error` notice and startup-failure line, and `dsh-stdio`'s startup-failure log lines. The live `agent/error` event and `SettleReason` preserve the thrown value as `unknown`; each diagnostic Consumer renders it instead of the loop wrapping it into another error. The per-package `renderThrown` copies in `dsh-agent-loop`, `dsh-stdio`, and `dsh-tui` are deleted in favor of the one shared renderer.
|
||||
- `dsh-stdio` renders failure `turn/end` reasons: `[turn failed <code>] <message>`, `[turn aborted] <reason>`, `[turn rejected] <reason>`, `[turn interrupted by a previous process exit]`, and the output-token-limit notice. Unknown merge-extended kinds fall through as ordinary turn ends.
|
||||
|
||||
`errorChain` lives in `dsh-llm` beside `HarnessError` for the same reason the base class does: it is the leaf package every consumer already imports, so sharing costs no new dependency edge.
|
||||
@@ -32,6 +32,6 @@ A TUI run against an unreachable DeepSeek endpoint failed with the single notice
|
||||
|
||||
- A transport failure now reads `DeepSeek API request to <baseURL> failed: fetch failed: connect ECONNREFUSED …` in the TUI notice, the readline transcript, and the persisted session log, at the cost of longer diagnostic strings.
|
||||
- Durable `turn/end` error messages include cause detail. Existing snapshot fixtures replay byte-identically because their scripted errors carry no `cause` (for such errors `errorChain(err)` equals `err.message`); only unit-test expectation strings changed. A fixture recorded from a real transport failure would carry the chain.
|
||||
- `errorChain` renders `message` without the class name (`String(error)` rendered `Error: <message>`), so a bare `TypeError` in a log line loses its type label unless its message is empty (then the name is the fallback). The chain detail was judged worth more than the class name at these seams.
|
||||
- `errorChain` renders `message` without the class name (`String(error)` rendered `Error: <message>`), so a bare `TypeError` in a log line loses its type label unless its message is empty (then the name is the fallback). The chain detail was judged worth more than the class name at these diagnostic boundaries.
|
||||
- `dsh-stdio` output for failed turns is no longer silent; piped consumers that parsed the transcript see new `[turn …]` lines.
|
||||
- Remaining `renderThrown` copies in `dsh-subagent`, `dsh-workflow`, `dsh-skill`, and `dsh-workflow-workerthread` still render without the chain; they wrap package-local errors that carry their own messages, and can adopt `errorChain` when their diagnostics prove insufficient.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: 在每个诊断界面渲染错误 cause 链
|
||||
# Agent Note: 在每个诊断边界渲染错误 cause 链
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -8,14 +8,14 @@ Status: implemented
|
||||
|
||||
TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` 通知,没有任何进一步细节。两个独立缺口共同造成了这个死胡同:
|
||||
|
||||
1. undici 的 `fetch` 把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的 `TypeError: fetch failed`,可操作的细节——`ECONNREFUSED`、`bad port`、Happy Eyeballs 的 AggregateError——都在 `error.cause` 上。harness 里的每个诊断界面都只渲染 `error.message`(或对 Error 等价的 `String(error)`),于是包装层在 TUI 通知、持久化的 `turn/end` reason 和所有日志行里都掩盖了诊断信息。
|
||||
1. undici 的 `fetch` 把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的 `TypeError: fetch failed`,可操作的细节——`ECONNREFUSED`、`bad port`、Happy Eyeballs 的 AggregateError——都在 `error.cause` 上。harness 里的每个诊断边界都只渲染 `error.message`(或对 Error 等价的 `String(error)`),于是包装层在 TUI 通知、持久化的 `turn/end` reason 和所有日志行里都掩盖了诊断信息。
|
||||
2. readline 前门(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。
|
||||
|
||||
## 决策
|
||||
|
||||
- `dsh-llm` 导出 `errorChain(value)`:渲染抛出值及其完整 `cause` 链(`outer: inner: …`)与 AggregateError 成员(`msg [m1; m2]`),并容错循环 cause 和恶意强制转换。它只是用于诊断界面的渲染器;路由仍然基于 `HarnessError.code`。
|
||||
- DeepSeek 适配器把拿到响应之前的传输失败包装成 `LlmError('TRANSPORT')`,写明配置的 `baseURL` 并将原始拒绝值作为 `cause` 串入错误链。被中止的请求变为 `LlmError('ABORTED')`;由于轮次信号已处于中止状态,agent loop(智能体循环)仍将该轮次归类为取消而非恢复。
|
||||
- 每个诊断界面改用 `errorChain` 而非 `error.message`/`String(error)`:agent-loop 的持久化 `turn/end` 错误消息(`errorData`)、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。实时 `agent/error` 事件与 `SettleReason` 以 `unknown` 原样保留抛出值;各诊断消费方自行渲染,而不是由循环把它包装成另一个错误。`dsh-agent-loop`、`dsh-stdio`、`dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。
|
||||
- 每个诊断边界改用 `errorChain` 而非 `error.message`/`String(error)`:agent-loop 的持久化 `turn/end` 错误消息(`errorData`)、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。实时 `agent/error` 事件与 `SettleReason` 以 `unknown` 原样保留抛出值;各诊断 Consumer 自行渲染,而不是由循环把它包装成另一个错误。`dsh-agent-loop`、`dsh-stdio`、`dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。
|
||||
- `dsh-stdio` 渲染失败的 `turn/end` reason:`[turn failed <code>] <message>`、`[turn aborted] <reason>`、`[turn rejected] <reason>`、`[turn interrupted by a previous process exit]` 以及输出 token 上限通知。通过声明合并扩展出的未知 kind 按普通 turn 结束处理。
|
||||
|
||||
`errorChain` 与 `HarnessError` 一样放在 `dsh-llm` 里,理由相同:它是每个消费方都已导入的叶子包,共享不增加新的依赖边。
|
||||
@@ -32,6 +32,6 @@ TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed`
|
||||
|
||||
- 传输失败现在在 TUI 通知、readline transcript(文本记录)和持久化会话日志里显示为 `DeepSeek API request to <baseURL> failed: fetch failed: connect ECONNREFUSED …`,代价是更长的诊断字符串。
|
||||
- 持久化的 `turn/end` 错误消息包含 cause 细节。现有快照 fixture(测试前置数据)字节级一致地回放,因为其脚本化错误不带 `cause`(对这类错误 `errorChain(err)` 等于 `err.message`);只有单元测试的期望字符串有变化。从真实传输失败录制的 fixture 会携带完整链。
|
||||
- `errorChain` 渲染 `message` 而不带类名(`String(error)` 会渲染 `Error: <message>`),因此日志行里的裸 `TypeError` 会丢失类型标签,除非消息为空(此时回退到类名)。在这些诊断界面上,链细节被判断为比类名更有价值。
|
||||
- `errorChain` 渲染 `message` 而不带类名(`String(error)` 会渲染 `Error: <message>`),因此日志行里的裸 `TypeError` 会丢失类型标签,除非消息为空(此时回退到类名)。在这些诊断边界上,链细节被判断为比类名更有价值。
|
||||
- `dsh-stdio` 对失败轮次的输出不再沉默;解析 transcript 的管道消费方会看到新的 `[turn …]` 行。
|
||||
- `dsh-subagent`、`dsh-workflow`、`dsh-skill`、`dsh-workflow-workerthread` 里剩余的 `renderThrown` 副本仍不渲染链;它们包装的是自带消息的包内错误,等诊断信息证明不足时再采用 `errorChain`。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.md: fb6524846f837b783423b3ed0e043cd4e1f7849d
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: e6072313b7089b9dda14c298fcb94db786220501
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: 96adc31d6d6c50b4e6bfee32a5b89388a33a6939
|
||||
|
||||
@@ -42,4 +42,4 @@ Status: implemented
|
||||
|
||||
- **单元:** `compact-basic.spec.ts` 断言辅助调用转发 `system`/`tools`/前导消息,并把压缩指令作为最后一条消息追加,且 `compactRegion` 回放最新的已路由 header 前缀。现有的内容断言通过回放的消息而非 transcript 字符串来读取摘要器输入。
|
||||
- **循环:** `compact-loop-repro.spec.ts` 依据摘要请求尾部 user 消息中的压缩指令对其分类,溢出恢复测试则继续在真实循环中固定对话请求与摘要请求的数量。
|
||||
- **快照:** 无密钥回放会从带标记的 `compact/summary` 重建一条规范成功流;[compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 负责持久标记契约。
|
||||
- **快照:** 无密钥回放会从带标记的 `compact/summary` 重建一条规范成功流;[compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 负责持久标记约定。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md
|
||||
2026-07-21-semantic-session-checkpoints.md: 697d878dccbfab1ded9f73e134d594ba6f0665e1
|
||||
2026-07-21-semantic-session-checkpoints.zh.md: c6184b0b00db16f9e318f4cbd148b1f0525f03a5
|
||||
2026-07-21-semantic-session-checkpoints.zh.md: c72f2bbad33967f54013f05568b9d1082d549357
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。在 `agent/pre-step` 时,该插件会在推导下一个请求前刷新待持久化的提示词输入或前一批响应/结果。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。循环的最终 `turn/end` 检查点仍是轮次的收尾边界,并会在处理另一个已排队轮次或观察到空闲状态之前完成。
|
||||
`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。在 `agent/pre-step` 时,该插件会在推导下一个请求前刷新待持久化的提示词输入或前一批响应/结果。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新当前会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。循环的最终 `turn/end` 检查点仍是轮次的收尾边界,并会在处理另一个已排队轮次或观察到空闲状态之前完成。
|
||||
|
||||
持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/pre-step` 监听器追加的事件是否先于本检查点;提示词输入以及前一批由循环自身记录的助手消息与有序结果都已在日志中。
|
||||
|
||||
@@ -26,4 +26,4 @@ ACP(Agent Client Protocol)应用在一个有序 Cordis effect 中统一持
|
||||
|
||||
## 后果
|
||||
|
||||
发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状,以及最终检查点的顺序与故障隔离;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。崩溃 harness 会等待预期的标记内容,而不是仅等待路径存在,因此文件在写入前因打开而可见时,不会导致该 harness 提前终止子进程。无密钥 ACP 与 SDK 快照证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,取消流程正常收尾时系统会持久化由循环实际生成的闭合边界,且 SDK 关闭流程会观察到已完整持久化的轮次。
|
||||
发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状,以及最终检查点的顺序与故障隔离;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化约定则证明各后端都支持这两种恢复分类。崩溃 harness 会等待预期的标记内容,而不是仅等待路径存在,因此文件在写入前因打开而可见时,不会导致该 harness 提前终止子进程。无密钥 ACP 与 SDK 快照证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,取消流程正常收尾时系统会持久化由循环实际生成的闭合边界,且 SDK 关闭流程会观察到已完整持久化的轮次。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.md
|
||||
2026-07-22-pi-ai-transport-truncation-classification.md: 119200a788c0b0521f385f4cf4e6adf05a0512f9
|
||||
2026-07-22-pi-ai-transport-truncation-classification.md: 4f645a26286bac3d95b285d1f37422797775e979
|
||||
2026-07-22-pi-ai-transport-truncation-classification.zh.md: 0625d6041ccaeaff9d0d6f659c7b3856ab985ef6
|
||||
|
||||
@@ -22,7 +22,7 @@ Classification stays on message text because that is the only signal pi-ai deliv
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Capture the `cause` via a pi-ai fetch/dispatcher/client hook.** Rejected: pi-ai 0.81.1 exposes none. `StreamOptions` offers only `onPayload`/`onResponse`; `onResponse` fires before the body stream is consumed, so it cannot observe a mid-stream drop. The Anthropic path accepts a `client` object, but constructing and injecting a provider SDK client per request to intercept transport errors reaches around the adapter seam for one diagnostic string.
|
||||
**Capture the `cause` via a pi-ai fetch/dispatcher/client hook.** Rejected: pi-ai 0.81.1 exposes none. `StreamOptions` offers only `onPayload`/`onResponse`; `onResponse` fires before the body stream is consumed, so it cannot observe a mid-stream drop. The Anthropic path accepts a `client` object, but constructing and injecting a provider SDK client per request to intercept transport errors reaches around the adapter boundary for one diagnostic string.
|
||||
|
||||
**Leave both as `PI_AI_ERROR` and widen `llm-retry`'s retryable set.** Rejected: `PI_AI_ERROR` is the catch-all for genuinely unclassified failures, including non-retryable ones (a malformed provider response, an unexpected SDK bug). Making the catch-all retryable would retry failures that will never succeed; the fix is to classify the recoverable case, not to blur the bucket.
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md
|
||||
2026-07-27-glob-sampling.md: 67912cf290b127a96819d57f30387197af68323d
|
||||
2026-07-27-glob-sampling.zh.md: e339a5f87b483849df60feb726b061fe85074300
|
||||
2026-07-27-glob-sampling.zh.md: b8d7bbe1bc70e94cc4239867244be554eaf33e63
|
||||
|
||||
@@ -22,17 +22,17 @@ Status: implemented
|
||||
|
||||
**只保留按修改时间排序的前部。** 测量实际故障形态后否决。某些部署需要这种稳定排序;但重视工作区定位的部署可以显式选择具有代表性的数据,而不必要求模型怀疑自己拿到的唯一一批路径。
|
||||
|
||||
**为采样选项提供默认值。** 否决。没有全产品范围的证据支持把任一排序作为隐式契约,因此每个组合都必须选择一种,配置错误则在加载时失败。
|
||||
**为采样选项提供默认值。** 否决。没有全产品范围的证据支持把任一排序作为隐式约定,因此每个组合都必须选择一种,配置错误则在加载时失败。
|
||||
|
||||
**对所有结果采样。** 否决。完整结果没有因截断损失任何信息,因此按修改时间排序仍有助于回答关注新旧时间的问题。只有当截取前部已经无法描述整体时,才开始采样。
|
||||
|
||||
**改为最新优先排序。** 否决。这只会改变哪一棵结果集中的子树可能占据主导;既取消了现有的最旧优先契约,也没有让受限页面更具代表性。
|
||||
**改为最新优先排序。** 否决。这只会改变哪一棵结果集中的子树可能占据主导;既取消了现有的最旧优先约定,也没有让受限页面更具代表性。
|
||||
|
||||
**仅在偏斜超过阈值时采样。** 否决。目前没有证据支持适用于所有部署的统一阈值,模型也无法判断当前采用的是哪一种排序契约。现有上限是可以清楚解释的切换点。
|
||||
**仅在偏斜超过阈值时采样。** 否决。目前没有证据支持适用于所有部署的统一阈值,模型也无法判断当前采用的是哪一种排序约定。现有上限是可以清楚解释的切换点。
|
||||
|
||||
**在顶层以下递归平衡。** 暂缓。按第一路径段做平衡已经修复观测到的故障;更深层的平衡需要一套尚无依据的深度与广度取舍策略。
|
||||
|
||||
**新增面向模型的 `list` 工具。** 实现评审后否决。默认编程组合已经提供通用 bash,模型也理解 `ls`;重复工具会永久增加 schema 与提示词所占的 token,并引入排序、分页、符号链接、转义、UI 与快照契约,却没有独立的安全或策略收益。不向模型提供 bash 工具的精简部署也不会因本次改动获得目录定位能力。
|
||||
**新增面向模型的 `list` 工具。** 实现评审后否决。默认编程组合已经提供通用 bash,模型也理解 `ls`;重复工具会永久增加 schema 与提示词所占的 token,并引入排序、分页、符号链接、转义、UI 与快照约定,却没有独立的安全或策略收益。不向模型提供 bash 工具的精简部署也不会因本次改动获得目录定位能力。
|
||||
|
||||
**拒绝 `*`,或在不含分隔符的模式前静默加上根目录锚点。** 否决。同样的「在任意深度匹配文件名」行为使 `*.ts` 可以有效地跨目录树搜索。记录这条规则能够保留正常工作的 Ripgrep 语义。
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.md: c3eeeca01a7820b5f410bd895de998e944e58eb2
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 388b67c67052074fa7423eae294e00b4122b2fe8
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 79a2758e7a7353c983f4e8a9d2531830dec311e4
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别名、spill locator、嵌入的事件时间和省略字节数,但刷新写回会持久化本次生成的原始值。因此,即使比较契约将两份日志视为相等,一次行为未发生变化的刷新仍会用新的随机值或宿主特有的路径写法改写 fixture(测试前置数据)。
|
||||
ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别名、spill locator、嵌入的事件时间和省略字节数,但刷新写回会持久化本次生成的原始值。因此,即使比较约定将两份日志视为相等,一次行为未发生变化的刷新仍会用新的随机值或宿主特有的路径写法改写 fixture(测试前置数据)。
|
||||
|
||||
消息身份所需的结构前提比记录对齐更弱:无关的日志事件可能破坏记录对齐,但继承而来的消息去除身份后的值在父级和子级日志之间仍保持不变。录制模式在替换现有 fixture 时也会从新生成的消息 UUID 开始。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-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: 210064f6e7c1ea239da89c4542346e164c48e274
|
||||
2026-07-28-load-pre-identity-session-messages.md: 694bf9ed9ec7a24399b5898665c2222a806f93c6
|
||||
2026-07-28-load-pre-identity-session-messages.zh.md: 23f55e1b67f988b6da0ae651f0b53059715eab86
|
||||
|
||||
@@ -28,7 +28,7 @@ The upgrade is read-only. Stored legacy records remain unchanged; a resumed sess
|
||||
|
||||
## 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.
|
||||
Pre-identity JSONL and SQLite sessions resume with their original message content, sources, assistant provider/model fields, 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.
|
||||
|
||||
|
||||
@@ -22,17 +22,17 @@ Status: implemented
|
||||
|
||||
**按照预发布兼容性立场拒绝这些日志。** 这是处理其他 v0 形状变动的默认方式,但即使每个旧字段都能明确映射到当前消息表示,它仍会导致真实的第一方会话无法恢复。
|
||||
|
||||
**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储契约,还需要为 JSONL 和 SQLite 分别实现原子替换机制,并将一次读取兼容性修复扩大为迁移系统。
|
||||
**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储约定,还需要为 JSONL 和 SQLite 分别实现原子替换机制,并将一次读取兼容性修复扩大为迁移系统。
|
||||
|
||||
**每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。
|
||||
|
||||
## 后果
|
||||
|
||||
消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、提供方溯源信息、工具调用关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。
|
||||
消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、assistant 的提供方/模型字段、工具调用关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。
|
||||
|
||||
这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享的协调器契约会在内存参考实现、JSONL 和 SQLite 后端上验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。
|
||||
这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享的协调器约定会在内存参考实现、JSONL 和 SQLite 后端上验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。
|
||||
|
||||
## 相关
|
||||
|
||||
- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.md):该记录负责当前的消息标识与不可变性契约。
|
||||
- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.md):该记录负责当前的消息标识与不可变性约定。
|
||||
- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.md):该记录负责仅追加后端与恢复边界。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md
|
||||
2026-07-28-themed-scrollbars-and-reserved-gutter.md: ba3d9d3c94cf9a775c43c14e292187b3a43935fe
|
||||
2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 51d668f584ee254183d5d1cf709140df2556e8d0
|
||||
2026-07-28-themed-scrollbars-and-reserved-gutter.md: 8ad5d801358823576c37d6b6824d7ce3fdcdac11
|
||||
2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: d6d2c64703df95ea2a4ff79435512eabdb3d2f81
|
||||
|
||||
@@ -22,7 +22,7 @@ The two renderings are mutually exclusive, and the exclusion is enforced rather
|
||||
|
||||
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. `transparent` is the pair's other legal target, added when the sidebar's bars [started following the pointer](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md); the gate below admits those two and nothing else. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls.
|
||||
|
||||
Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were missed in the first implementation and found in review, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection.
|
||||
Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were initially missed, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection.
|
||||
|
||||
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.
|
||||
|
||||
@@ -44,7 +44,7 @@ The gutter and the sheet's `::-webkit-scrollbar` width are jointly necessary aga
|
||||
|
||||
**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.
|
||||
**Declare the standard properties and the pseudo-elements unconditionally, without the `@supports` gate.** 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.
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ Status: implemented
|
||||
|
||||
两种渲染互斥,而这种互斥是被强制的,不是假定的。`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 上。这组变量另一个合法的目标是 `transparent`,它随侧边栏滚动条[改为跟随指针](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md)一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。
|
||||
两条路径都读取同一组间接变量 `--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 上。这组变量另一个合法的目标是 `transparent`,它随侧边栏滚动条[改为跟随指针](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md)一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。
|
||||
|
||||
`Menu`、`InputBar`、`QuestionComposer` 与 `TodoPanel` 这四个表面在最初的实现里被漏掉、由评审发现,因此逐样式表的重新绑定契约由机械检查而非人工审阅把关。
|
||||
`Menu`、`InputBar`、`QuestionComposer` 与 `TodoPanel` 这四个表面最初被漏掉,因此逐样式表的重新绑定约定由机械检查而非人工审阅把关。
|
||||
|
||||
抬升表面集合是从调色板自身的暗色抬升阶梯解析出来的——暗色取值落在 `bg-layer-2` 或 `bg-layer-3` 上的那些表面 token,而这一档正是 l1/l2 之分所编码的层级差。最初的做法是从已经做了重新绑定的样式表反向推导,那是不成立的:这样得到的集合只能确认别人已经记得的部分,而尚无人重新绑定的表面——恰恰就是这项检查存在的理由——会把自己定义成「非抬升」。`--dsw-specific-tip` 证明了这一点:它解析到与菜单表面相同的那一档,待办面板在它上面滚动却没有重新绑定,而推导式的检查依然是绿的。
|
||||
|
||||
@@ -42,9 +42,9 @@ Status: implemented
|
||||
|
||||
**把这两个属性绑定在 `html` 上。** 这是文档级皮肤最自然的落点,而它的失败是可测量的:规则挂在 `html` 上时,chromium 中滚动容器计算出的 `scrollbar-color` 为 `auto`,因为别名 token 在那个作用域内不存在。
|
||||
|
||||
**只声明一次,靠继承下传。** 匹配的元素更少,但它破坏重新绑定契约——继承携带的是代入后的颜色,而不是变量引用,因此抬升表面无法给自己的滚动条换色。它本身也不完整,因为 `scrollbar-width` 不继承。
|
||||
**只声明一次,靠继承下传。** 匹配的元素更少,但它破坏重新绑定约定——继承携带的是代入后的颜色,而不是变量引用,因此抬升表面无法给自己的滚动条换色。它本身也不完整,因为 `scrollbar-width` 不继承。
|
||||
|
||||
**不加 `@supports` 门禁,无条件同时声明标准属性与伪元素。** 这正是本次变更最初提交的形态,被评审发现。在 chromium 中于带 `scrollbar-gutter: stable`(使条带可观测)的探针元素上实测:单独一条 8px 的 `::-webkit-scrollbar` 预留出 30px 条带(样式表指定的宽度加上浏览器自带的按钮),而给同一元素加上 `scrollbar-width: thin` 后降到 `thin` 所预留的 10px——说明伪元素规则是被丢弃,而不是被合并。全部 `::-webkit-scrollbar-thumb:hover` 规则随之失效,因此两个 hover token 与四处抬升表面的 hover 重新绑定,在多数用户实际使用的引擎上都是死代码。
|
||||
**不加 `@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。未知选择器本就会被丢弃,因此这道门禁不提供任何能抵偿该代价的保护。
|
||||
|
||||
@@ -57,7 +57,7 @@ Status: implemented
|
||||
- 客户端的每个滚动容器都绘制带主题的滑块:亮色基础表面为 `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 *` 匹配所有元素,涉及的两个属性其效果本就被浏览器限制在实际会滚动的元素上。代价是一个覆盖面很宽的选择器;另一种选择是一个不生效的重新绑定契约。
|
||||
- `body *` 匹配所有元素,涉及的两个属性其效果本就被浏览器限制在实际会滚动的元素上。代价是一个覆盖面很宽的选择器;另一种选择是一个不生效的重新绑定约定。
|
||||
- 工作区列表在任何列表长度下都永久少了预留空位那一条宽度。这正是该修复换来的代价:以稳定的行几何,换掉只在列表较短时才可读的时间戳。
|
||||
- 调色板中没有轨道 token,因此日后若设计需要不透明轨道,要新增一个别名 token,而不是在这张样式表里写字面颜色。
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md
|
||||
2026-07-28-web-agent-runtime-context.md: 6c2bb8faf0db56d2a29b47726ab295c3501cce2d
|
||||
2026-07-28-web-agent-runtime-context.zh.md: 09cbbdeabc244c265381b1ad67c3629b4c0763f5
|
||||
2026-07-28-web-agent-runtime-context.zh.md: 810f5df6712116baf1d00a7040f6f7be9b73fea1
|
||||
|
||||
@@ -6,27 +6,27 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
CLI 共享 base 配置了空的部署 persona,Web overlay 没有替换它,而 Web 启动器既未添加源码提示词段,也未添加交互界面提示词段。会话 header 会记录工作目录,供工具与持久化使用,但模型提示词既不说明该目录,也不标识 DeepSeek Harness Web GUI。因此,当用户提出「修改这个页面的主题」之类的请求时,即使用户指的是承载当前会话的 GUI,agent 也只能在所选项目中搜索一个未明确说明的页面。
|
||||
CLI(命令行界面)共享 base 配置了空的部署 persona,Web overlay 没有替换它,而 Web 启动器既未添加源码提示词段,也未添加交互界面提示词段。会话 header 会记录工作目录,供工具与持久化使用,但模型提示词既不说明该目录,也不标识 DeepSeek Harness Web GUI。因此,当用户提出「修改这个页面的主题」之类的请求时,即使用户指的是承载当前会话的 GUI,agent(智能体)也会在所选项目中搜索一个未明确说明的页面。
|
||||
|
||||
## 决策
|
||||
|
||||
Web profile 会组合 `dsh-base` 与 `dsh-web-app` 两个组合包。Web 组合包提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`;当 `surfaceContext` 为 true 时,其 `web-runtime` 插件会添加 `app:web-surface` 提示词段。挂载 profile 配置树前,`dsh web` 别名会读取组合后的同一项设置,并且仅在启用表层上下文时安装现有的 `harness:source` 提示词段。无头组合包和拥有完整提示词的 profile 都会设置 `surfaceContext: false`,从而抑制 Web 提示词与受管 shell 事实;Web 别名也会抑制源码提示词段,而无需检查 overlay 路径。每项已挂载的提示词贡献仍会在 agent loop(智能体循环)等消费方发出 request header 前激活。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。
|
||||
Web profile 会组合 `dsh-base` 与 `dsh-web-app` 两个组合包。Web 组合包提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`;当 `surfaceContext` 为 true 时,其 `web-runtime` 插件会添加 `app:web-surface` 提示词段。挂载 profile 配置树前,`dsh web` 别名会读取组合后的同一项设置,并且仅在启用界面上下文时安装现有的 `harness:source` 提示词段。无头组合包和拥有完整提示词的 profile 都会设置 `surfaceContext: false`,从而抑制 Web 提示词与受管 shell 事实;Web 别名也会抑制源码提示词段,而无需检查 overlay 路径。每项已挂载的提示词贡献仍会在 agent loop(智能体循环)等消费方发出 request header 前激活。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。
|
||||
|
||||
Web 提示词段把未限定的「这个页面」「这个 GUI」或「这个应用」解释为 DeepSeek Harness Web GUI。同时,它会明确说明浏览器不会隐式提供 DOM、路由或截图上下文,使模型能够识别产品,但不会声称掌握未收到的视觉状态。组装后的文本会记录在 `request/header` 中,从而保持「模型可见内容必须有日志记录」这一不变量。
|
||||
|
||||
## 验证
|
||||
|
||||
Web 运行时单元测试会固定启用和禁用 `surfaceContext` 时的行为,Web 别名单元测试则会固定组合后配置行对源码提示词段的默认启用与显式禁用门控。无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web 组合包,通过 HTTP/SSE 应用运行一个真实会话,并在规范化源码路径与工作目录后对系统提示词前缀生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。Core Web 快照会应用 RL overlay,并固定不含源码提示词段或 Web 提示词段的完整系统提示词。
|
||||
Web 运行时单元测试会固定启用和禁用 `surfaceContext` 时的行为,Web 别名单元测试则会固定组合后配置行对源码提示词段的默认启用与显式禁用门控。无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web 组合包,通过 HTTP/SSE(Server-Sent Events)应用运行一个真实会话,并在规范化源码路径与工作目录后对系统提示词前缀生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。Core Web 快照会应用 RL overlay,并固定不含源码提示词段或 Web 提示词段的完整系统提示词。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**每次提示词都发送 URL、DOM 或截图。** 本次故障只需要稳定的产品定位;当前根 URL 无法标识所选组件,消息契约中也不存在视觉捕获内容。添加动态页面状态需要另行设计可记录的模型输入,不属于本次修复的隐含范围。
|
||||
**每次提示词都发送 URL、DOM 或截图。** 本次故障只需要稳定的产品定位;当前根 URL 无法标识所选组件,消息约定中也不存在视觉捕获内容。添加动态页面状态需要另行设计可记录的模型输入,不属于本次修复的隐含范围。
|
||||
|
||||
**要求会话 Workspace 必须是 harness checkout。** Workspace cwd 是用户任务的目标,可以合理地指向空项目或其他仓库。将其与应用源码位置混为一谈会破坏这一边界,并且仍无法消除已安装版本或外部启动会话中的歧义。
|
||||
|
||||
**把 Web 文案放入全局 harness 身份。** `dsh-system-prompt` 还服务于 TUI、ACP、SDK 和不在浏览器中运行的自定义部署。该界面事实应由组装 Web 应用负责。
|
||||
**把 Web 文案放入全局 harness 身份。** `dsh-system-prompt` 还服务于 TUI、ACP(Agent Client Protocol)、SDK 和不在浏览器中运行的自定义部署。该界面事实应由组装 Web 应用负责。
|
||||
|
||||
**为所有 CLI 界面修改现有源码位置提示词段。** TUI 也复用源码位置提示词段,而该段只陈述 checkout 事实。单独保留 Web 界面定位可以维持这份可复用契约,避免错误地告诉无头或终端 agent 它们正处于浏览器中。
|
||||
**为所有 CLI 界面修改现有源码位置提示词段。** TUI 也复用源码位置提示词段,而该段只陈述 checkout 事实。单独保留 Web 界面定位可以维持这份可复用约定,避免错误地告诉无头或终端 agent 它们正处于浏览器中。
|
||||
|
||||
## 影响
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md
|
||||
2026-07-28-web-gui-feedback-loop.md: aa488e1df087722c072d98c67d47cdf63a42f6b8
|
||||
2026-07-28-web-gui-feedback-loop.zh.md: d9de988e5a2cc57794ff628b65eb050dee610747
|
||||
2026-07-28-web-gui-feedback-loop.zh.md: 9b6954092b737920ed18bc412037e917512e40dc
|
||||
|
||||
@@ -14,15 +14,15 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道
|
||||
|
||||
常规 `dsh web` 组合会挂载 Web 组合包的 `web-runtime` 插件,由它发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI,并给出 URL;`DSH_WEB_URL` 和 `DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。拥有完整提示词的 profile 会把该配置行的 `surfaceContext` 设为 false,并且不会收到该提示词段和这些受管变量中的任何一个;Web 启动器也会使用同一项设置来抑制其源码 checkout 提示词段。
|
||||
|
||||
按模式区分的提示词让 agent 而非用户负责隐藏的启动契约。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明,`dsh web --dev` 只会启用 HMR(热模块替换)接收端:客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程,agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。
|
||||
按模式区分的提示词让 agent 而非用户负责隐藏的启动约定。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明,`dsh web --dev` 只会启用 HMR(热模块替换)接收端:客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程,agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。
|
||||
|
||||
`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR 入口路径。Vite 构建模式保持不变。
|
||||
|
||||
静态产物发生变化时,不需要仅为此重启或替换服务器。宿主会在每次请求时读取 `index.html` 和静态资源,客户端 bundle 也会从当前文件提供,并设置 `no-cache`;因此,重新构建相关外壳与插件 bundle 后,刷新现有 URL 就是验收路径。启动另一个服务器只能证明另一个服务器可用。如果用户明确要求再启动一个长期运行的服务器,则现有受管后台任务契约负责其生命周期和完成通知;shell `&` 不能替代这套生命周期机制。
|
||||
静态产物发生变化时,不需要仅为此重启或替换服务器。宿主会在每次请求时读取 `index.html` 和静态资源,客户端 bundle 也会从当前文件提供,并设置 `no-cache`;因此,重新构建相关外壳与插件 bundle 后,刷新现有 URL 就是验收路径。启动另一个服务器只能证明另一个服务器可用。如果用户明确要求再启动一个长期运行的服务器,则现有受管后台任务约定负责其生命周期和完成通知;shell `&` 不能替代这套生命周期机制。
|
||||
|
||||
## 验证
|
||||
|
||||
无密钥的 fresh-round-trip 浏览器场景会启动已交付的生产 Web 组合,驱动真实的回放会话,对包含 URL 和模式的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 和 `$DSH_WEB_MODE` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web --dev` 并捕获模型提供方请求,从而固定完整的双命令开发契约。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;浏览器 HMR 场景会启动 `dsh web --dev`,修改生产初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。
|
||||
无密钥的 fresh-round-trip 浏览器场景会启动已交付的生产 Web 组合,驱动真实的回放会话,对包含 URL 和模式的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 和 `$DSH_WEB_MODE` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web --dev` 并捕获模型提供方请求,从而固定完整的双命令开发约定。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;浏览器 HMR 场景会启动 `dsh web --dev`,修改生产初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -36,4 +36,4 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道
|
||||
|
||||
## 影响
|
||||
|
||||
常规 Web 提示词会增加一个动态 URL 和模式段落,因此模型提供方的前缀复用会随绑定端口和模式变化。相应的 Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL/模式契约会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。禁用 `surfaceContext` 的 profile 也会放弃这项反馈闭环指引与 shell 上下文。
|
||||
常规 Web 提示词会增加一个动态 URL 和模式段落,因此模型提供方的前缀复用会随绑定端口和模式变化。相应的 Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL/模式约定会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。禁用 `surfaceContext` 的 profile 也会放弃这项反馈闭环指引与 shell 上下文。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md
|
||||
2026-07-29-human-transcript-append-origin.md: 9804d3d8d67a4ad00c3d395c2081fd47e03c254a
|
||||
2026-07-29-human-transcript-append-origin.zh.md: 909f0756dd29c9deddb840a188eb19a773f92d29
|
||||
2026-07-29-human-transcript-append-origin.md: fe60ab6e745fdc08d84deb4a6e2e4fe8168a23e8
|
||||
2026-07-29-human-transcript-append-origin.zh.md: b8fe6f6e4b9b5298f9d07bdf395e33e33b4a200d
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-29-human-transcript-append-origin.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessages` counted every `user/message` and `assistant/message` in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only provenance and the replacement that cites it.
|
||||
The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessages` counted every `user/message` and `assistant/message` in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only `compact/summary` event and the replacement that cites it.
|
||||
|
||||
Nothing was lost from the log. `Session.events` still held every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection.
|
||||
|
||||
@@ -18,13 +18,13 @@ The terminal replays the transcript from append-origin surface events and keeps
|
||||
|
||||
A checkpoint is recognized through the compaction seam's own contract — `isCompactCheckpointSource`, the backend-independent marker `CompactService` requires on the replacement user message — so the terminal depends on the declared vocabulary, not on the shape of the replacement. `dsh-session-reference` already consumes that predicate to project another session's log; this is the same question asked by a different reader. Other replacements are silent: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation.
|
||||
|
||||
`session.history` counts only append-origin messages toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compact/summary` provenance stays on the page of the replacement that cites it.
|
||||
`session.history` counts only append-origin messages toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compact/summary` event stays on the page of the replacement that cites it.
|
||||
|
||||
No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required.
|
||||
|
||||
## Deferred
|
||||
|
||||
The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint's provenance group, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page.
|
||||
The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint and its cited source events as a unit, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but when the checkpoint was the oldest counted message, the old pagination rule happened to include the whole shadowed range on the same page.
|
||||
|
||||
The terminal's [archived live compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses standalone bracket events to drive the existing one-cell indicator. It does not change the completion marker owned here or add scale: the checkpoint's `sourceEventSeqs` remain available for a separately justified count or range. Progress therefore needs neither marker-content changes nor a prerequisite `renderReplacement(event)` extraction.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
终端与宿主历史网关都把模型可见的 surface 当作人类可读记录(transcript)。一次成功的压缩(compaction)会用一个检查点节点替换一段 surface 范围,因此该替换一落地,终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页:`maxMessages` 统计窗口内的每个 `user/message` 和 `assistant/message`,于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度,而切分点还可能落在压缩的仅日志溯源信息与引用它的替换之间。
|
||||
终端与宿主历史网关都把模型可见的 surface 当作人类可读记录(transcript)。一次成功的压缩(compaction)会用一个检查点节点替换一段 surface 范围,因此该替换一落地,终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页:`maxMessages` 统计窗口内的每个 `user/message` 和 `assistant/message`,于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度,而切分点还可能落在压缩的仅日志 `compact/summary` 事件与引用它的替换之间。
|
||||
|
||||
日志本身没有丢失任何内容。`Session.events` 仍保存着每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。
|
||||
|
||||
@@ -16,21 +16,21 @@ Status: implemented
|
||||
|
||||
终端从追加来源的 surface 事件回放记录,并通过 `transcriptToolCallIds` 让被遮蔽步骤的工具卡片保持配对:该函数读取追加来源的 `assistant/message`,而不是 surface 成员关系。已落地的压缩会在其自身日志位置贡献一行暗色 `… earlier context was compacted …`:这行标记报告模型从何处起不再看到那段历史,而不是把它抹掉。带框的检查点载荷从不渲染,且两条路径都按同一个标记对 surface 事件分类,因此实时到达的压缩与恢复后回放同一份日志会产生相同的记录。只有回放会重新推导 `tool/call` 的配对关系:调用事件自身不携带标记,其归属继承自公布它的 `assistant/message`,而实时监听器必然刚刚渲染过后者。
|
||||
|
||||
检查点通过压缩接缝自身的契约来识别——`isCompactCheckpointSource`,即 `CompactService` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇,而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志;这里只是另一个读者提出同样的问题。其他替换保持静默:被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点,并不在对话中标出边界。
|
||||
检查点通过压缩接缝自身的约定来识别——`isCompactCheckpointSource`,即 `CompactService` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇,而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志;这里只是另一个读者提出同样的问题。其他替换保持静默:被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点,并不在对话中标出边界。
|
||||
|
||||
`session.history` 只把追加来源的消息计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compact/summary` 溯源信息会与引用它的替换留在同一页。
|
||||
`session.history` 只把追加来源的消息计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compact/summary` 事件会与引用它的替换留在同一页。
|
||||
|
||||
持久事件、RPC 信封、压缩事务与模型可见的 surface 都没有变化,也不需要迁移。
|
||||
|
||||
## Deferred
|
||||
|
||||
浏览器客户端在[Web 记录投影笔记](2026-07-30-web-transcript-log-ordered-projection.md)中单独修复:它按日志顺序投影同一份 append 来源记录并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会按检查点的溯源分组切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页。
|
||||
浏览器客户端在[Web 记录投影笔记](2026-07-30-web-transcript-log-ordered-projection.md)中单独修复:它按日志顺序投影同一份 append 来源记录并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会在检查点与检查点引用的来源事件这个整体内切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但当检查点是最旧的被计数消息时,旧分页规则会把整段被遮蔽的范围放在同一页。
|
||||
|
||||
终端的[已归档实时压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用独立标记对中的事件驱动现有的单格指示器。它既不改变本文所负责的完成标记,也不添加规模信息:检查点的 `sourceEventSeqs` 仍可供经另行论证的计数或区间使用。因此,进度显示既不需要修改标记内容,也不以提取 `renderReplacement(event)` 为前置条件。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**按形态识别检查点(一个替换型 `user/message`)。** 被否决:那读取的是当前生产者的巧合而非已声明的契约,而未来任何用用户消息替换一段范围的生产者都会静默地继承压缩标记。接缝已经发布 `COMPACT_CHECKPOINT_SOURCE`,正是为了让消费方与后端无关地识别检查点。
|
||||
**按形态识别检查点(一个替换型 `user/message`)。** 被否决:那读取的是当前生产者的巧合而非已声明的约定,而未来任何用用户消息替换一段范围的生产者都会静默地继承压缩标记。接缝已经发布 `COMPACT_CHECKPOINT_SOURCE`,正是为了让消费方与后端无关地识别检查点。
|
||||
|
||||
**继续把检查点渲染为注入上下文卡片。** 被否决:带框的检查点是为模型撰写的指令信封,不是人类对话内容。展示它却隐藏它替换掉的历史,正好颠倒了读者的需要。
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md
|
||||
2026-07-29-pnpm-setup-runner-isolation.md: 74b672b3f90ea445ad1a8e283a5904056059b2f8
|
||||
2026-07-29-pnpm-setup-runner-isolation.zh.md: 32c667dc09e561504e8e053bf2a338ed2190d9e8
|
||||
2026-07-29-pnpm-setup-runner-isolation.zh.md: fde4cb747e41ea7dafe87ca3ccd0e872c45dd3b1
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
[主 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)。
|
||||
[主 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 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。
|
||||
|
||||
@@ -24,4 +24,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
pnpm 可执行文件采用临时安装,并按 runner 隔离;包下载仍使用已配置的持久或缓存 store。托管作业使用相同的显式目标目录,不改变缓存政策。工作流中的每个设置步骤因此增加三行配置;只有在 pnpm 提供机制有意迁移到另一种隔离机制时,才需要更新回归测试。
|
||||
pnpm 可执行文件采用临时安装,并按 runner 隔离;包下载仍使用已配置的持久或缓存 store。托管作业使用相同的显式目标目录,不改变缓存政策。工作流中的每个设置步骤因此增加三行配置;只有在 pnpm 配置有意改用另一种隔离机制时,才需要更新回归测试。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
|
||||
2026-07-29-sticky-composer-conversation-scroll.md: 8657080078e475d6e90f17a6c0f8cb5b2fb1555d
|
||||
2026-07-29-sticky-composer-conversation-scroll.zh.md: a588903adf51c6fcdd01f851d4f5e6570f695242
|
||||
2026-07-29-sticky-composer-conversation-scroll.md: d628e10979318ba371d56f61c5e96363b4135ad7
|
||||
2026-07-29-sticky-composer-conversation-scroll.zh.md: 7b9a59933d979577ea50d339f4bb276557e67081
|
||||
|
||||
@@ -26,7 +26,7 @@ Chat history prepend follows reader intent through stable rendered node/call ide
|
||||
|
||||
**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
|
||||
|
||||
**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership were left outside the provenance model instead of adding a general input state machine. The [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md) later closed this deferral by generalizing attribution through the observed-top ledger, still without an input state machine.
|
||||
**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership were left outside the input-source model instead of adding a general input state machine. The [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md) later closed this deferral by generalizing attribution through the observed-top ledger, still without an input state machine.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
# Agent Note: 固定标题栏,sticky 编辑器位于 transcript 滚动容器内
|
||||
# Agent Note: 固定标题栏,sticky 编辑器位于 transcript(文本记录)滚动容器内
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-sticky-composer-conversation-scroll.md) | 中文
|
||||
|
||||
## Problem
|
||||
## 问题
|
||||
|
||||
活跃会话列把滚动拆成两段:聊天(以及 trajectory)视图自有 `overflow-y: auto`,编辑器栈则作为该滚动容器的兄弟节点坐在下方。指针落在统计行或输入区上时,滚轮打在不可滚动区域上因而毫无效果——只有指针在消息列表上时 transcript 才会移动。草稿变长时更糟:textarea 本身也是滚动容器,编辑器上的滚轮可能被截在那里。会话标题栏必须以普通 chrome 占据列顶(不能在滚动容器内 `position: sticky`),而编辑器必须与 transcript 贴在同一滚动容器底部,使页脚上的滚轮能带动内容流动。
|
||||
|
||||
## Decision
|
||||
## 决策
|
||||
|
||||
`ConversationRoot` 始终拥有同一个 `data-conversation-scroll` 主体,其中严格 `conversation.session` view outlet 位于 `data-composer-seat` 之前;该 seat 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。独立的严格 `conversation.session.header` outlet 作为 `flex: none` 列 chrome 位于滚动容器上方,并在 Session 仍为 blank 时隐藏。固定的父级树让滚动主体与 composer seat 从无 session、blank Hero 到活跃对话始终保持挂载。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 Question/Approval 接管仍可见;Hero CSS 在滚动主体内居中 fallback 栈。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。
|
||||
`ConversationRoot` 始终拥有同一个 `data-conversation-scroll` 主体,其中严格 `conversation.session` view outlet 位于 `data-composer-seat` 之前;该 seat 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。独立的严格 `conversation.session.header` outlet 作为 `flex: none` 列 chrome 位于滚动容器上方,并在 Session 仍为 blank 时隐藏。固定的父级树让滚动主体与 composer seat 从无会话、blank Hero 到活跃对话始终保持挂载。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 Question/Approval 接管仍可见;Hero CSS 在滚动主体内居中 fallback 栈。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。
|
||||
|
||||
会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。
|
||||
|
||||
Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态;读者输入如何被识别——即以与设备无关的方式偏离由最近一次交付或写入的 `scrollTop` 构成的 observed-top ledger——由[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)负责。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。
|
||||
Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态;读者输入如何被识别——即以与设备无关的方式偏离由最近一次交付或写入的 `scrollTop` 构成的 observed-top ledger——由[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)负责。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个分片不会触发第二次滚动写入。
|
||||
|
||||
## Alternatives considered
|
||||
## 考虑过的替代方案
|
||||
|
||||
**标题栏与编辑器都在同一列滚动容器内 sticky。** 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。
|
||||
|
||||
@@ -28,6 +28,6 @@ Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图
|
||||
|
||||
**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权当时被留在输入来源模型之外,也未为此新增通用输入状态机。[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)后来通过 observed-top ledger 泛化了归因,补上了这一延后事项,且仍未引入输入状态机。
|
||||
|
||||
## Consequences
|
||||
## 后果
|
||||
|
||||
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留读者的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段的收缩钳制。贴底跟随所有权依据[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)扩展到每一种读者输入。无 session → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。
|
||||
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky 轮次标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留读者的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段的收缩钳制。贴底跟随所有权依据[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)扩展到每一种读者输入。无会话 → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md
|
||||
2026-07-29-web-details-session-lifecycle.md: 41b89fc059a02e56f53b27e5a5b48fb9488b93d7
|
||||
2026-07-29-web-details-session-lifecycle.zh.md: 82fb5e0c2608cccbb786a21971a74958417b4f10
|
||||
2026-07-29-web-details-session-lifecycle.zh.md: b6d7ecdf19382b4742dad6e032be930ae10c5f1e
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
**在 New Session 点击处理器中关闭详情栏。** 之所以否决:未选中表面没有会话作用域的详情,不得修改几何信息。详情栏是否关闭,应由随后对两个已定义会话 owner 的比较决定。
|
||||
|
||||
**按会话持久化面板几何信息。** 之所以否决:产品契约需要移除陈旧上下文,而不是新增一张保存各宽度的映射。按会话保存几何信息还会在用户返回时重新打开详情栏,与选定的离开即关闭行为相悖。
|
||||
**按会话持久化面板几何信息。** 之所以否决:产品约定需要移除陈旧上下文,而不是新增一张保存各宽度的映射。按会话保存几何信息还会在用户返回时重新打开详情栏,与选定的离开即关闭行为相悖。
|
||||
|
||||
**在会话基线就绪后保留持久化布局。** 之所以否决:这会仅为验证陈旧的查看状态,在呈现组件中重复实现启动生命周期。瞬时默认值无需就绪标志即可使重新加载具有确定性。
|
||||
|
||||
@@ -26,4 +26,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
详情栏在启动时保持关闭,首次会话物化时亦然。显式打开操作会使用契约默认宽度。切换到不同会话会忘记拖动后的详情宽度,因为关闭操作会写入零值,重新打开时则使用该默认值。未选中状态会将轨道的渲染宽度派生为零,同时保持首选几何信息不变;经由这些状态返回同一会话时,会恢复其宽度。重新加载会忘记侧边栏几何信息,并使详情栏恢复关闭状态。布局行为测试覆盖初始默认值、首次物化、直接及经 hero 中转的会话切换、返回同一会话,以及不存在布局存储的情况;无密钥浏览器 e2e 则通过已交付的组合驱动相同的 owner 过渡,同时检查完整网格轨道和浏览器错误。
|
||||
详情栏在启动时保持关闭,首次会话物化时亦然。显式打开操作会使用约定默认宽度。切换到不同会话会忘记拖动后的详情宽度,因为关闭操作会写入零值,重新打开时则使用该默认值。未选中状态会将轨道的渲染宽度派生为零,同时保持首选几何信息不变;经由这些状态返回同一会话时,会恢复其宽度。重新加载会忘记侧边栏几何信息,并使详情栏恢复关闭状态。布局行为测试覆盖初始默认值、首次物化、直接及经 hero 中转的会话切换、返回同一会话,以及不存在布局存储的情况;无密钥浏览器 e2e 则通过已交付的组合驱动相同的 owner 过渡,同时检查完整网格轨道和浏览器错误。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md
|
||||
2026-07-30-approval-panel-command-cap.md: 1e4b75c106b71605a004ef35301445da76ec8cf0
|
||||
2026-07-30-approval-panel-command-cap.zh.md: aa9f708b78c8ace735e1c96c0ff9ab0a7a4d526a
|
||||
2026-07-30-approval-panel-command-cap.md: 1d74d790299522a0f1e6b23f342cd10b35192d27
|
||||
2026-07-30-approval-panel-command-cap.zh.md: d31db819144cd4ce2099bf5d5f025b2e8df0e0e1
|
||||
|
||||
@@ -47,6 +47,6 @@ Reproducing the off-screen buttons needs a card taller than the scrollport, not
|
||||
|
||||
The geometry block and the golden are replay-only, so record mode reaches the fixture write instead of aborting on layout.
|
||||
|
||||
The scenario keeps exactly one golden — the waiting panel — and asserts the answered state on the world instead (the decided outcome, the file the escalated command wrote, `DONE`, the panel gone, the composer re-enabled). An answered-transcript golden was recorded first and failed on Linux CI: the denied first attempt renders the OS's own refusal, and that text is platform-specific (`bash: notes.txt: Operation not permitted` on macOS against `bash: line 1: notes.txt: Read-only file system` on Linux). Any scenario whose transcript contains a sandbox-denied command inherits that, so the denial belongs in assertions, never in a golden.
|
||||
The scenario keeps exactly one golden — the waiting panel — and asserts the answered state on the world instead (the decided outcome, the file the escalated command wrote, `DONE`, the panel gone, the composer re-enabled). An answered-transcript golden cannot hold: the denied first attempt renders the OS's own refusal, and that text is platform-specific (`bash: notes.txt: Operation not permitted` on macOS against `bash: line 1: notes.txt: Read-only file system` on Linux). Any scenario whose transcript contains a sandbox-denied command inherits that, so the denial belongs in assertions, never in a golden.
|
||||
|
||||
The panel ships as a client-module bundle: `pnpm run build:web` alone does not pick up a change to `ApprovalPanel.module.css` or a new `data-` hook in `ApprovalPanel.tsx` — the package build must run first, or the browser lane asserts against an older client than the tree.
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
面板的理由与命令移入同一个滚动区域(`data-approval-scroll`),其高度上限与 composer 的草稿区完全相同;琥珀色状态条与操作按钮行位于该区域之外,因此无论内容多长,两个按钮都留在卡片内。
|
||||
|
||||
这个上限是一个值、两个消费者,以 `--dsh-composer-text-max-height: 336px` 声明在 `ConversationRoot` 的 `.composerSeat` 上——它是 composer 链唯一的共同祖先,因为兜底的 InputBar 与被选中的接管面板是兄弟节点。`InputBar` 的草稿滚动容器与面板的滚动区域都读取它,于是同一个容器不可能给它的两种状态设出不同上限:设计同学要求的"可以跟输入框最大高度统一",如今是样式表中的一个事实,而不是抄在两个文件里的一个数字。该区域取 `box-sizing: border-box`,因此上限指的是它的外框高度,与 composer 草稿区占据的是同一个盒子。
|
||||
这个上限是一个值、两个消费者,以 `--dsh-composer-text-max-height: 336px` 声明在 `ConversationRoot` 的 `.composerSeat` 上——它是 composer 链唯一的共同祖先,因为兜底的 InputBar 与被选中的接管面板是兄弟节点。`InputBar` 的草稿滚动容器与面板的滚动区域都读取它,于是同一个容器不可能给它的两种状态设出不同上限:设计同学要求的「可以跟输入框最大高度统一」,如今是样式表中的一个事实,而不是抄在两个文件里的一个数字。该区域取 `box-sizing: border-box`,因此上限指的是它的外框高度,与 composer 草稿区占据的是同一个盒子。
|
||||
|
||||
该区域自身是一个 Tab 停靠点(`tabIndex={0}`,带名称的 `role="group"`)。提问 composer 的滚动体不需要这样做——它的选项行本身可聚焦,会把容器一起带过去;而这里除文本之外别无内容:没有自己的停靠点,仅用键盘的用户能走到按钮却走不到命令尾部,于是可能批准了自己没读完的东西。
|
||||
|
||||
@@ -22,31 +22,31 @@ Status: implemented
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**给整张卡片设上限,而不是给文本区域设。** 一条声明,不需要重构结构,而且它读起来就是字面意义上的"与输入框相同的最大高度"。之所以否决:卡片还装着状态条和操作按钮行——总高 336px 时,理由与命令只能分到约 250px,比它们所取代的草稿区更矮,而且两边数字能对上纯属状态条高度的巧合。给文本区域设上限,才能让两种状态在同一文本高度处收住,而这正是让底部不再跳动的那条性质。
|
||||
**给整张卡片设上限,而不是给文本区域设。** 一条声明,不需要重构结构,而且它读起来就是字面意义上的「与输入框相同的最大高度」。之所以否决:卡片还装着状态条和操作按钮行——总高 336px 时,理由与命令只能分到约 250px,比它们所取代的草稿区更矮,而且两边数字能对上纯属状态条高度的巧合。给文本区域设上限,才能让两种状态在同一文本高度处收住,而这正是让底部不再跳动的那条性质。
|
||||
|
||||
**像提问 composer 那样按视口设上限(`min(60vh, 520px)`)。** 同为接管面板的兄弟组件已经这么做了,因此这是本地既有先例。之所以否决:设计同学的要求是与 InputBar 对齐,而两个接管面板形态并不相同——提问 composer 的滚动内容是一组需要用户互相比较的选项,能占多少视口就该占多少;审批面板的滚动内容则是一条命令,用户在决定之前扫读即可。按视口设上限还会让容器高度在被选中时再次跳动,只是方向相反。
|
||||
|
||||
**对命令做省略号或截断处理。** 不需要滚动区域,不需要上限,按钮也不会移位。之所以否决:命令正是被审批的对象,隐去它的尾部等于要求用户为自己读不到的文本背书。在这里截断还是不可恢复的——面板就是审批的全部界面,没有"展开更多"的落脚处。
|
||||
**对命令做省略号或截断处理。** 不需要滚动区域,不需要上限,按钮也不会移位。之所以否决:命令正是被审批的对象,隐去它的尾部等于要求用户为自己读不到的文本背书。在这里截断还是不可恢复的——面板就是审批的全部界面,没有「展开更多」的落脚处。
|
||||
|
||||
**把操作按钮行留在滚动区域内,只给该区域设上限。** 比把按钮行固定住少动几处。之所以否决:这会把缺陷搬进卡片内部——按钮滚出该区域,用户得先发现有滚动条才能碰到它们。
|
||||
|
||||
## 后果
|
||||
|
||||
- 长命令在卡片内滚动,拒绝/允许按钮留在屏幕内。在构建产物客户端上于 900x1000 与 900x700 实测:该区域报告的 `scrollHeight` 超过 `clientHeight`,两个按钮都留在卡片内、也都留在视口内。
|
||||
- 选中接管面板不再改变 composer 容器能达到的高度,因此审批到来或解决时,上方的会话流不会有数百像素的重排。
|
||||
- 选中接管面板不再改变 composer 容器能达到的高度,因此审批到来或解决时,上方的 transcript(文本记录)不会有数百像素的重排。
|
||||
- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析,且落在真正滚动草稿的那个盒子上([两层文本共用同一个滚动容器](2026-07-31-composer-text-layers-share-one-scrollport.md)把该声明从自增高镜像层移了出去)。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 `var()`),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。
|
||||
- 该场景录制的命令是一段 200 个 token 的字符块,远超一次往返所需。这个代价是有意付出的:没有能越过上限的内容,这个上限无法被证伪,而模型会把任何规整的载荷压缩掉(第一次录制时,模型把"alpha 重复 400 次"写成了 `printf 'alpha %.0s' {1..400}`,一条什么也证明不了的单行命令)。
|
||||
- 该场景录制的命令是一段 200 个 token 的字符块,远超一次往返所需。这个代价是有意付出的:没有能越过上限的内容,这个上限无法被证伪,而模型会把任何规整的载荷压缩掉(第一次录制时,模型把「alpha 重复 400 次」写成了 `printf 'alpha %.0s' {1..400}`,一条什么也证明不了的单行命令)。
|
||||
|
||||
## 验证
|
||||
|
||||
`apps/web/tests/approval-composer.e2e.ts` 驱动的是真实组合:一个只读会话、一次被拒绝的写入、模型的越权重试,以及在面板上点击完成的回应。几何断言在两个视口高度上针对活动面板执行,并有守卫防止它空洞地成立——该区域必须确实处在滚动状态,且实测上限必须等于 composer 自身的上限,后者由测试在发送之前从活动的草稿滚动容器上读出,而不是把该像素值写死。
|
||||
|
||||
在构建产物客户端上双向确认过。撤销上限后,该区域报告 `scrolls: false`,并长到命令的完整高度(900x1000 下,录制的字符块为 1798px,而设上限后为 336px);在 900x700 下卡片高 680px、视口高 700px,操作按钮行底边落在 y=749——正在折叠之下,与设计同学的反馈完全一致。恢复上限后,该场景在回放模式下通过。
|
||||
在构建产物客户端上双向确认过。撤销上限后,该区域报告 `scrolls: false`,并长到命令的完整高度(900x1000 下,录制的字符块为 1798px,而设上限后为 336px);在 900x700 下卡片高 680px、视口高 700px,操作按钮行底边落在 y=749——位于视口下方,与设计同学的反馈完全一致。恢复上限后,该场景在回放模式下通过。
|
||||
|
||||
要复现按钮跑到屏幕外,需要的是比滚动视口更高的卡片,而不只是一张很高的卡片。composer 容器为 `position: sticky; bottom: 0`,因此在卡片尚能容纳时它会一直吸附在视口底部,按钮仍然可见——在 900x1000 下,未设上限的卡片吃掉了整个会话流,却仍把操作按钮行留在屏幕内。只有当卡片长过滚动视口,sticky 才再也无法守住底边,按钮行随之沉入折叠之下。
|
||||
要复现按钮跑到屏幕外,需要的是比滚动视口更高的卡片,而不只是一张很高的卡片。composer 容器为 `position: sticky; bottom: 0`,因此在滚动视口尚能容纳卡片时它会一直吸附在视口底部,按钮仍然可见——在 900x1000 下,未设上限的卡片吃掉了整个 transcript,却仍把操作按钮行留在屏幕内。只有当卡片长过滚动视口,sticky 才再也无法守住底边,按钮行随之沉入视口下方。
|
||||
|
||||
几何断言块与 golden 仅在回放模式下执行,这样录制模式才能走到写入 fixture 那一步,而不是在布局检查处中断。
|
||||
几何断言块与 golden 仅在回放模式下执行,这样录制模式才能走到写入 fixture(测试前置数据)那一步,而不是在布局检查处中断。
|
||||
|
||||
该场景只保留一份 golden —— 等待中的面板;回应之后的状态改为对世界作断言(决策结果、越权命令写出的那个文件、`DONE`、面板消失、输入框重新可用)。最初还录了一份"已回应会话流"的 golden,它在 Linux CI 上失败了:第一次被拒绝的尝试渲染的是操作系统自己的拒绝文本,而这段文本因平台而异(macOS 为 `bash: notes.txt: Operation not permitted`,Linux 为 `bash: line 1: notes.txt: Read-only file system`)。任何会话流中含有被沙箱拒绝命令的场景都会继承这一点,因此这类拒绝只能进断言,绝不能进 golden。
|
||||
该场景只保留一份 golden——等待中的面板;回应之后的状态改为对世界作断言(决策结果、越权命令写出的那个文件、`DONE`、面板消失、输入框重新可用)。一份"已回应 transcript"的 golden 立不住:第一次被拒绝的尝试渲染的是操作系统自己的拒绝文本,而这段文本因平台而异(macOS 为 `bash: notes.txt: Operation not permitted`,Linux 为 `bash: line 1: notes.txt: Read-only file system`)。任何 transcript 中含有被沙箱拒绝命令的场景都会继承这一点,因此这类拒绝只能进断言,绝不能进 golden。
|
||||
|
||||
该面板以客户端模组包的形式发布:单跑 `pnpm run build:web` 不会带上对 `ApprovalPanel.module.css` 的改动,也不会带上 `ApprovalPanel.tsx` 中新增的 `data-` 钩子——必须先执行包构建,否则浏览器测试通道会对着一个比工作树更旧的客户端做断言。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md
|
||||
2026-07-30-composer-context-stack-order.md: 150225ce27fa8459703170622cef754b7694e2fc
|
||||
2026-07-30-composer-context-stack-order.zh.md: f13ffc3c1136707a86ba5636a5b69304a132df01
|
||||
2026-07-30-composer-context-stack-order.zh.md: dc9c75b35fb6a34c06326c916fd422483d57289c
|
||||
|
||||
@@ -10,15 +10,15 @@ Goal、Todo 与 Queue 独立注册到同一个 `conversation.input.dock` 列表
|
||||
|
||||
## 决策
|
||||
|
||||
[Todo 优先的对齐决策](2026-08-02-todo-first-composer-context-order.md)规定当前的升序排列。本记录保留围绕该顺序的堆栈契约:数值间隔使未来条目可以声明预期位置,不必依赖插件激活顺序;composer bar 位于列表之后。
|
||||
[Todo 优先的对齐决策](2026-08-02-todo-first-composer-context-order.md)规定当前的升序排列。本记录保留围绕该顺序的堆栈约定:数值间隔使未来条目可以声明预期位置,不必依赖插件激活顺序;composer bar 位于列表之后。
|
||||
|
||||
`ConversationRoot` 负责独立上下文卡片之间的 6px 间距。Goal 是一张独立的 752×36px 卡片,折叠后的 Todo 是一张独立的 752×44px 卡片。Queue 是末端 dock 条目:其 776px 包装层包含相同的 752px 面板列,并减去共享间距与具名的 5px 布局重叠量,因此后渲染的 composer 卡片只覆盖 Queue 边缘。空条目渲染为 null,不占用间距。
|
||||
|
||||
顺序与重叠是两项独立契约。注册顺序定义语义层级,stack 上的 CSS 变量定义共享几何。系统不能仅因 Queue 是最后一个可见条目,就推断它可以与 composer 重叠,因为没有 Queue 时,Goal 或 Todo 可能成为最后一个可见上下文卡片,而它们必须与 composer 保持间隔。
|
||||
顺序与重叠是两项独立约定。注册顺序定义语义层级,stack 上的 CSS 变量定义共享几何。系统不能仅因 Queue 是最后一个可见条目,就推断它可以与 composer 重叠,因为没有 Queue 时,Goal 或 Todo 可能成为最后一个可见上下文卡片,而它们必须与 composer 保持间隔。
|
||||
|
||||
## 验证
|
||||
|
||||
注册测试固定了三个顺序值。无密钥 Queue 浏览器场景同时渲染 Todo、Goal 和 Queue,固定它们的无障碍顺序,并检查其可见卡片边缘;聚焦的 Goal 与 Queue 场景覆盖各自的独立状态。
|
||||
注册测试固定了三个顺序值。无密钥 Queue 浏览器场景同时渲染 Todo、Goal 和 Queue,固定它们的无障碍顺序,并检查其可见卡片边缘;分别针对 Goal 与 Queue 的场景覆盖各自的独立状态。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -26,7 +26,7 @@ Goal、Todo 与 Queue 独立注册到同一个 `conversation.input.dock` 列表
|
||||
|
||||
**在 `ConversationRoot` 中分别渲染每个已知 dock id。** 不予采纳,因为这会把可扩展的列表 slot 变成硬编码的组件清单,并迫使 owner 在每新增一个注册方时随之修改。
|
||||
|
||||
**让最后一个 dock 条目贴卡。** 不予采纳,因为 Goal 和 Todo 是独立卡片;Goal 或 Todo 缺席时的组合不得改变剩余卡片的界面语义。
|
||||
**让最后一个 dock 条目与 composer 相接。** 不予采纳,因为 Goal 和 Todo 是独立卡片;Goal 或 Todo 缺席时的组合不得改变剩余卡片的界面语义。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md
|
||||
2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1
|
||||
2026-07-30-hover-popup-pointer-grace.zh.md: db10e156103284383f911684b2c92977a0315275
|
||||
2026-07-30-hover-popup-pointer-grace.zh.md: f53d77bae7b7f223621c4c42779e1a6a616aa485
|
||||
|
||||
@@ -6,13 +6,13 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭,其卡片还设置了 `pointer-events: none`;但卡片位于锚点右边缘外 8px 处,因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域,卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 `closeOnPointerLeave`,而其处理器挂在传送后的列表上:把指针移回打开该列表的 `...` 触发按钮会关闭列表,越过列表边缘的任何一次抖动同样如此,且没有任何折返窗口。
|
||||
工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭,其卡片还设置了 `pointer-events: none`;但卡片位于锚点右边缘外 8px 处,因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域,卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 `closeOnPointerLeave`,而其处理器挂在传送后的列表上:把指针移回打开该列表的 `...` 触发按钮会关闭列表,指针越过列表边缘同样会将其关闭,且没有任何折返窗口。
|
||||
|
||||
## 决策
|
||||
|
||||
`usePointerGrace`([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))持有唯一一个可取消的延迟关闭,由两个原子组件共享,`POINTER_GRACE_MS` 为 200。离开会启动关闭,折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙,而真正移开的指针仍会关闭弹层。
|
||||
|
||||
`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择,不会关闭卡片;只有锚点区域内的按下和所有者将 `disabled` 置真,才会抢在宽限期之前立即关闭卡片。
|
||||
`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择,不会关闭卡片;只有在锚点区域内发生按下操作,或所有者将 `disabled` 置真时,才会抢在宽限期之前立即关闭卡片。
|
||||
|
||||
`Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮,都不再算作离开。只有在列表打开时才会启动离开关闭;由所有者驱动的关闭(选择、Escape、外部点击)会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect,则每次重新渲染都会取消宽限期,因为所有者每次都传入新的 `onClose` 闭包。
|
||||
|
||||
@@ -20,7 +20,7 @@ Status: implemented
|
||||
|
||||
**仅通过外部点击和 Escape 关闭这两种弹层。** 之所以否决:两者都由悬停唤起,且没有可见的关闭标识;在指针已移到其他行之后仍让它们停留,会把卡片遗留在无关内容之上。
|
||||
|
||||
**扩大锚点的命中区域,使其与弹层相接。** 之所以否决:8px 与 4px 的偏移来自设计稿,而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和缩放时执行的每一次重新定位。
|
||||
**扩大锚点的命中区域,使其与弹层相接。** 之所以否决:8px 与 4px 的偏移来自设计稿,而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和调整窗口大小时执行的每一次重新定位。
|
||||
|
||||
**保留悬浮卡片的 `pointer-events: none`,只加入宽限期。** 之所以否决:那样指针停在卡片上时命中的是卡片背后的元素,宽限期仍会到期,并关闭用户刚刚够到的卡片。
|
||||
|
||||
@@ -28,7 +28,7 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
悬浮卡片现在可被命中,显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价;它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返,而因自身原因关闭的菜单也不会被残留的待执行关闭重新关掉。未设置 `closeOnPointerLeave` 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。
|
||||
悬浮卡片现在可被命中,显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价;它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返,而因自身原因关闭的菜单重新打开后,也不会被残留的待执行关闭关掉。未设置 `closeOnPointerLeave` 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md
|
||||
2026-07-30-multi-select-custom-answer-composition.md: 581beec89a0f0018ec2df687f5dfe1b1b5b86d22
|
||||
2026-07-30-multi-select-custom-answer-composition.zh.md: 5c9cb59822aca3fbf49fbbdf522c76f963df3480
|
||||
2026-07-30-multi-select-custom-answer-composition.zh.md: 796d379312483be5063a3a5d67dd6a0d0e37803e
|
||||
|
||||
@@ -18,8 +18,8 @@ Status: implemented
|
||||
|
||||
**把自定义文本编码为另一个 `selected` 标签。** 不予采纳,因为这样会抹去调用方提供的选项标签与用户填写文本之间的区别,削弱校验,并迫使消费方推断哪个值属于自定义内容。
|
||||
|
||||
**允许所有问题同时使用 `selected` 与 `custom`。** 不予采纳,因为单选题只表示一个回答;允许选中选项与自定义文本并存会使其基数含义模糊。组合形式仅适用于显式选择多项回答的问题。
|
||||
**允许所有问题同时使用 `selected` 与 `custom`。** 不予采纳,因为单选题只表示一个回答;允许选中选项与自定义文本并存会使其基数含义模糊。组合形式仅适用于显式启用多回答的问题。
|
||||
|
||||
## 后果
|
||||
|
||||
多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的覆盖率、TUI 覆盖率、宿主响应覆盖率和工具投影覆盖率共同固定组合结果。Web、TUI 与工具投影覆盖率还固定了仅含标签的回答形态;组装后的无密钥 TUI 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。
|
||||
多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的测试覆盖、TUI 测试覆盖、宿主响应测试覆盖和工具投影测试覆盖共同固定组合结果。Web、TUI 与工具投影测试覆盖还固定了仅含标签的回答形态;组装后的无密钥 TUI 测试覆盖固定终端中的组合回答流程,单选题的宿主测试覆盖则固定其余的互斥规则。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md
|
||||
2026-07-30-source-checkout-workdir-distinction.md: ba6d9dd12b55a54d4ae8d2e91ad83ac3c1dc47fd
|
||||
2026-07-30-source-checkout-workdir-distinction.zh.md: ffc2ac7baa2b1bb8ce54607638c35869fc338825
|
||||
2026-07-30-source-checkout-workdir-distinction.zh.md: 06b09f82f294a70795090a9aec1b2220bd6e7512
|
||||
|
||||
@@ -4,30 +4,30 @@ Status: implemented
|
||||
|
||||
[English](2026-07-30-source-checkout-workdir-distinction.md) | 中文
|
||||
|
||||
## Problem
|
||||
## 问题
|
||||
|
||||
`harness:source` 提示词段遵循[源码位置决策](../../archived/feature/2026-07-21-dsh-system-prompt-source-path.md),但原有措辞把 checkout 称为“你自己的源代码”,却没有区分该路径与会话 workspace。在 persona 不声明 `{{cwd}}` 的普通 TUI 配置中,这可能是系统提示词开头附近唯一固定的绝对路径。因此,DeepSeek V4 可能会直接用 harness checkout 回答“what's the workdir?”,而不是确定会话的当前工作目录。
|
||||
`harness:source` 提示词段遵循[源码位置决策](../../archived/feature/2026-07-21-dsh-system-prompt-source-path.md),但原有措辞把 checkout 称为「你自己的源代码」,却没有区分该路径与会话 workspace。在 persona 不声明 `{{cwd}}` 的普通 TUI 配置中,这可能是系统提示词开头附近唯一固定的绝对路径。因此,DeepSeek V4 可能会直接用 harness checkout 回答「what's the workdir?」,而不是确定会话的当前工作目录。
|
||||
|
||||
直接断言 checkout 不是工作目录同样不准确。`dsh meta` 会有意让源码 checkout 同时充当这两个值。
|
||||
|
||||
## Decision
|
||||
## 决策
|
||||
|
||||
该提示词段将路径标识为“DeepSeek Harness implementation checkout”。它说明 checkout 位置与当前工作目录是两个可能不同的值,禁止从 checkout 路径推断工作目录,指示模型使用 `pwd`,并限定该 checkout 只用于检查或扩展 DSH 自身。
|
||||
该提示词段将路径标识为「DeepSeek Harness implementation checkout」。它说明 checkout 位置与当前工作目录是两个可能不同的值,禁止从 checkout 路径推断工作目录,指示模型使用 `pwd`,并限定该 checkout 只用于检查或扩展 DSH 自身。
|
||||
|
||||
路径推导方式、全局 `harness:source` 所有权和 `-99` 顺序均保持不变。将两者描述为概念上独立、而不是始终不相等,使这条指令在普通项目会话和 `dsh meta` 中都准确。
|
||||
|
||||
## Verification
|
||||
## 验证
|
||||
|
||||
`dsh-app-boot` 单元测试固定了完整文本及其顺序。CLI 无密钥 PTY 冒烟测试检查组装后的请求 header。TUI 的 `source-checkout-workdir` 快照把该提示词段挂载为 `/opt/dsh-source`,通过录制的 DeepSeek V4 turn 提问“what's the workdir?”,并要求回放 transcript 运行 `pwd`,报告生成的 workspace 而不是 checkout。
|
||||
`dsh-app-boot` 单元测试固定了完整文本及其顺序。CLI(命令行界面)无密钥 PTY 冒烟测试检查组装后的请求 header。TUI 的 `source-checkout-workdir` 快照把该提示词段挂载为 `/opt/dsh-source`,通过录制的 DeepSeek V4 turn 提问「what's the workdir?」,并要求回放 transcript(文本记录)运行 `pwd`,报告生成的 workspace 而不是 checkout。
|
||||
|
||||
## Alternatives considered
|
||||
## 考虑过的替代方案
|
||||
|
||||
**声明 checkout 永远不是工作目录。**拒绝:`dsh meta` 会有意让它们指向同一路径。
|
||||
|
||||
**把当前工作目录写入全局源码提示词段。**拒绝:源码提示词段由 launcher 全局持有,而工作目录属于各个会话;将两者合并会重复 loop 对 `cwd` 的所有权,还会让稳定的源码事实随 agent 变化。
|
||||
**把当前工作目录写入全局源码提示词段。**拒绝:源码提示词段由 launcher 全局持有,而工作目录属于各个会话;将两者合并会与 agent loop(智能体循环)对 `cwd` 的所有权重复,还会让稳定的源码事实随 agent 变化。
|
||||
|
||||
**从提示词中删除源码路径。**拒绝:launcher 从无关项目启动时,自引用 DSH 工具仍需要可靠的 checkout 位置。
|
||||
|
||||
## Consequences
|
||||
## 后果
|
||||
|
||||
提示词会变长,直接询问工作目录时可能多花一次廉价的 `pwd` 工具调用。作为交换,模型不再把 harness 实现路径当作隐含的任务 workspace;当 meta 模式使两个值重合时,提示词仍然准确。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
|
||||
2026-07-30-web-transcript-log-ordered-projection.md: 3b7aaeb1178ff79e38a1b9646a9dc78efaeae48b
|
||||
2026-07-30-web-transcript-log-ordered-projection.zh.md: acd198b6d3f3c5d57e233e09ee66f5d151e4f8f1
|
||||
2026-07-30-web-transcript-log-ordered-projection.md: 1c65eeec0fbf0dc550043bb809ae8d7310f0602b
|
||||
2026-07-30-web-transcript-log-ordered-projection.zh.md: 67bb41adde46ba7112896404ddc92a05f08f7aca
|
||||
|
||||
@@ -18,7 +18,7 @@ Node order is seq-monotonic by construction, and three things follow. The log-on
|
||||
|
||||
`foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended.
|
||||
|
||||
The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes those fields unavailable, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves them.
|
||||
The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left that event outside makes those fields unavailable, the same soft-fall as a call-less tool result, and a later page supplying the event resolves them.
|
||||
|
||||
The [manual compaction command](../feature/2026-07-30-queued-manual-compaction.md) returns the summary event's seq as the successful `CommandResult.sourceEventSeq`, and `command/done` persists that optional reference. Chat pairs only a successful named `/compact` command whose reference equals exactly one loaded `CompactionSummaryNode.summaryEventSeq`. The running command first renders `compact · Compacting context…`; after the checkpoint lands, the same React key renders one collapsed `compact` disclosure at the checkpoint's flow position with the count and token estimate. Input rejection, no compactable history, cancellation, and failure remain generic command rows with complete handler-authored text. Automatic compaction has no command reference and keeps the standalone context-compacted marker.
|
||||
|
||||
@@ -33,19 +33,19 @@ What is unreachable from a `packages/client/*` program is `dsh-compact`'s **root
|
||||
The repo's answer to exactly this is a cordis-free leaf subpath, and this change adds one: `COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource` now live in `packages/compact/compact/src/checkpoint.ts`, which imports no cordis and augments no module (the `dsh-commands/brand` / `dsh-llm/message` shape), and the root re-exports both so every host-side consumer — the terminal's chat helpers, `dsh-session-reference`'s projection — is unchanged. The adapter pins its literal to that declaration with a type-only import:
|
||||
|
||||
```ts
|
||||
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
|
||||
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
|
||||
import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
|
||||
const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact'
|
||||
```
|
||||
|
||||
Renaming the seam's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`.
|
||||
Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`.
|
||||
|
||||
`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` stays as the behavioral half, driving the adapter with a checkpoint built from the canonical **value**. The test value-imports the cordis-free `@deepseek-ai/dsh-compact/checkpoint` leaf and deliberately never loads the compact package root or the host-side `Context` merges reachable through it.
|
||||
`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it.
|
||||
|
||||
The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type.
|
||||
|
||||
## What #835's positional anchors were for, and why they are dissolved rather than lost
|
||||
|
||||
The unmerged manual-compaction-queueing branch fixes the same interleaving bug by recording a per-event anchor — the surface tail at append time — and retargeting shadowed anchors onto the checkpoint. That mechanism exists to make positional anchors survive surface **reordering**. The human transcript is never re-ordered, so anchors have nothing to retarget: the precondition is removed, not the fix discarded. The mechanism is absent from this base and is not authored here.
|
||||
The unmerged manual-compaction-queueing branch fixes the same interleaving bug by recording a per-event anchor — the surface tail at append time — and retargeting shadowed anchors onto the checkpoint. That mechanism exists to make positional anchors survive surface **reordering**. The human transcript is never re-ordered, so anchors have nothing to retarget: the precondition is removed, not the fix discarded. The mechanism does not exist in this codebase.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它
|
||||
|
||||
`foldDegraded` 从 `ConversationSnapshot` 消失,随之消失的是哨兵填充、它们所需的 `baseSeq` 算术,以及 `degradedSeqs()`。它们的存在只为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所描述的 fold 已不再运行。删除该标志是修复的一部分,而非修复之后的清理——`degradedSeqs()` 本身已几乎就是按日志顺序的投影,只是作为抛错后的落点而非本意到达。
|
||||
|
||||
标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时这些字段不可用,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出它们。
|
||||
标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把该事件留在窗口外时这些字段不可用,与无调用的工具结果同一种软退让;后续补上该事件的分页会解析出它们。
|
||||
|
||||
[手动压缩命令](../feature/2026-07-30-queued-manual-compaction.md)会把摘要事件的 seq 作为成功结果的 `CommandResult.sourceEventSeq` 返回,`command/done` 则持久化这项可选引用。Chat 只会配对成功且名称为 `/compact`、其引用恰好等于唯一一个已加载 `CompactionSummaryNode.summaryEventSeq` 的命令。运行中的命令先渲染为 `compact · Compacting context…`;检查点落地后,同一个 React key 会在检查点的消息流位置渲染一条收起的 `compact` 展开项,并显示条目数量和 token 估算值。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行,并保留处理器撰写的完整文本。自动压缩没有命令引用,继续使用独立的上下文已压缩标记。
|
||||
|
||||
@@ -33,19 +33,19 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它
|
||||
本仓库对这一情形的既有答案是不含 cordis 的叶子子路径,本次变更就新增了一个:`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource` 现在住在 `packages/compact/compact/src/checkpoint.ts`,它不导入 cordis、也不增强任何模块(即 `dsh-commands/brand` / `dsh-llm/message` 的形状),而包根重新导出两者,因此每个宿主侧消费方——终端的 chat helper、`dsh-session-reference` 的投影——都不需改动。适配器用仅类型导入把它的字面量钉在该声明上:
|
||||
|
||||
```ts
|
||||
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
|
||||
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
|
||||
import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
|
||||
const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact'
|
||||
```
|
||||
|
||||
重命名缝隙的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。
|
||||
重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。
|
||||
|
||||
`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。该测试以值导入方式从不含 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 叶子路径取得该值,并刻意不加载 compact 包根或经由它可达的宿主侧 `Context` 合并。
|
||||
`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。
|
||||
|
||||
因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。
|
||||
|
||||
## #835 的位置锚点是为什么而存在,以及为什么它是被溶解而非丢失
|
||||
|
||||
尚未合并的排队式手动压缩分支用另一种方式修同一个交错缺陷:为每个事件记录一个锚点——追加时的 surface 尾部——并把被遮蔽的锚点重定向到检查点上。该机制的存在是为了让位置锚点在 surface **重排**中存活。人类对话记录永不被重排,因此锚点没有任何东西需要重定向:前提被移除,修复并未被丢弃。该机制在本基线上并不存在,本次也不撰写它。
|
||||
尚未合并的排队式手动压缩分支用另一种方式修同一个交错缺陷:为每个事件记录一个锚点——追加时的 surface 尾部——并把被遮蔽的锚点重定向到检查点上。该机制的存在是为了让位置锚点在 surface **重排**中存活。人类对话记录永不被重排,因此锚点没有任何东西需要重定向:前提被移除,修复并未被丢弃。该机制在本代码库中并不存在。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -53,15 +53,15 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
|
||||
|
||||
**一条纯形状规则**——任何 replacement `user/message` 都是压缩。已拒绝:它今天正确只因为压缩是 replacement `user/message` 的唯一生产者,一旦这点改变便无任何机制能捕获。那个 pin 测试只花一个文件,就精确消除了这一风险。
|
||||
|
||||
**在宿主侧给检查点打标**,经投影或线协议。已拒绝:这最贴合“经 cordis 服务协作”的规则,但客户端今天折叠的是原始 `SessionEvent`,因此这意味着一次线协议契约变更——为一个纯谓词付出的代价不成比例。
|
||||
**在宿主侧给检查点打标**,经投影或线协议。已拒绝:这最贴合“经 cordis 服务协作”的规则,但客户端今天折叠的是原始 `SessionEvent`,因此这意味着一次线协议约定变更——为一个纯谓词付出的代价不成比例。
|
||||
|
||||
**把冻结节点的归属移进适配器**(`nodes(extraNodes)`),像那个未合并分支所做的那样。已拒绝:被打断的节点来自 `Session` 已经在窗口上运行的 `turn/end` 清扫,而在按 seq 单调的记录之上,简单形态就是正确的——适配器返回节点,会话按 seq 归并冻结节点。加宽适配器签名什么也换不到,还会把清扫与它的产物拆开。
|
||||
|
||||
**把 `foldDegraded` 留作一个防御性标志。** 已拒绝:它描述的是一个已不再运行的 fold 的特定失败。一个消费方无法据以行动、只能通过 `console.error` 到达的标志,是一份虚假契约。
|
||||
**把 `foldDegraded` 留作一个防御性标志。** 已拒绝:它描述的是一个已不再运行的 fold 的特定失败。一个消费方无法据以行动、只能通过 `console.error` 到达的标志,是一份虚假约定。
|
||||
|
||||
**把最近的 `/compact` 行与下一个检查点配对。** 已拒绝:两者之间可能落入上下文注入,并发或格式异常的生命周期记录也必须降级而不误取其他检查点。命令结果则指明权威摘要事件;引用存在歧义时不配对任何内容。
|
||||
|
||||
**解析英文结算文本中的条目数量和 token 数量。** 已拒绝:处理器文案是呈现文本,而非稳定的数据契约。标记读取本已持有这两个值的结构化 `compact/summary` 载荷。
|
||||
**解析英文结算文本中的条目数量和 token 数量。** 已拒绝:处理器文案是呈现文本,而非稳定的数据约定。标记读取本已持有这两个值的结构化 `compact/summary` 载荷。
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -69,7 +69,7 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
|
||||
|
||||
`ConversationNode` 增加第八个分支,因此每个穷尽消费方都多一个分支:`MessageItem` 通过新的 `CompactionItem` 渲染标记,trajectory 布局加宽它的“无单元格”分支,使标记不贡献单元格但仍推进耗时游标。
|
||||
|
||||
性能契约未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。
|
||||
性能约定未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。
|
||||
|
||||
Web e2e 场景现在围绕它录制的那一轮上的压缩事务播种一次真实的手动命令生命周期,因此 aria 基准经真实宿主与真实浏览器钉住完整行为:录制的提问与完整工具输出仍在屏幕上,其后恰好一条 `compact` 行报告规模,展开后会显示确切摘要。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出手动压缩。
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md
|
||||
2026-07-31-composer-text-layers-share-one-scrollport.md: ba11384409714d6a64a964d63197705acf39d213
|
||||
2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 9a4a2bcc9f947d385d1ff9fc6367b9a3eab17733
|
||||
2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 7a2500a6253c7cf50a57458b66c9b80503414ace
|
||||
|
||||
@@ -6,13 +6,13 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)):`<textarea>` 持有取值、选区与光标,但它自己的字形以 `color: transparent` 渲染;用户看到的每一个字符都由其下的 `[data-input-backdrop]` 层绘制,该层同时承载 claim token 高亮、chip 与提示影子文本。这一拆分正是 chip 与高亮得以存在的前提——textarea 无法为自身文本的某个区间单独设置样式。草稿框的高度上限为 14 行,因此超过上限之后总得有东西滚动。
|
||||
composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)):`<textarea>` 持有值、选区与光标,但它自己的字形以 `color: transparent` 渲染;用户看到的每一个字符都由其下的 `[data-input-backdrop]` 层绘制,该层同时承载 claim token 高亮、chip 与提示影子文本。这一拆分正是 chip 与高亮得以存在的前提——textarea 无法为自身文本的某个区间单独设置样式。草稿框的高度上限为 14 行,因此超过上限之后总得有东西滚动。
|
||||
|
||||
两层各自持有一个滚动偏移,会分两个阶段失效,本次改动是其中的第二个。
|
||||
|
||||
第一个阶段是静态的。backdrop 为 `position: absolute; inset: 0; overflow: hidden`——只裁剪、不滚动——浏览器也不会把它的偏移与 textarea 关联起来,于是超过上限之后光标在动而文字冻结在第 1 行。未达上限时这一点不可见,因为两层都停在 0;该缺陷与高度上限同龄,藏在所有截图所捕获的那个静止状态背后。当时的修复是用一个 `scroll` 监听把 textarea 的 `scrollTop` 镜像到 backdrop 上,这让两层在**静止时**保持一致。
|
||||
|
||||
第二个阶段正是随后被用户报告的现象:从长草稿的顶部快速滑到底部时,光标像带着惯性一样从自己的文字里往上飞出去,过一会儿又落回原位。原因就是那个镜像。滚轮手势在合成线程上滚动 textarea,不经过主线程;而驱动赋值的 `scroll` 事件在其后才派发,因此中间这些帧里光标位于新偏移、每一个字形却仍位于旧偏移。在同一套几何的独立环境上实测:把偏移改变 200px 并在本任务结束之前读取光标与其字形之间的距离,chromium 分离 203px、firefox 202px、WebKit 203px,一两帧之后才收敛到固定的行盒常量(3/2/3)。每个引擎、每次手势都如此,且滑动越快分离越远。
|
||||
第二个阶段正是随后被用户报告的现象:从长草稿的顶部快速滑动时,光标像带着惯性一样从自己的文字里往上飞出去,过一会儿又落回原位。原因就是那个镜像。滚轮手势在合成线程上滚动 textarea,不经过主线程;而驱动赋值的 `scroll` 事件在其后才派发,因此中间这些帧里光标位于新偏移、每一个字形却仍位于旧偏移。在同一套几何的独立环境上实测:把偏移改变 200px 并在本任务结束之前读取光标与其字形之间的距离,chromium 分离 203px、firefox 202px、WebKit 203px,一两帧之后才收敛到固定的行盒常量(3/2/3)。每个引擎、每次手势都如此,且滑动越快分离越远。
|
||||
|
||||
任何监听都无法消除这个间隙,因为这个间隙正是监听的定义:它在自己所响应的那件事之后才运行。凡是用 JavaScript 维持两个盒子相等的做法,都会落后于那个不经询问就搬动其中一个盒子的合成器一帧。
|
||||
|
||||
@@ -52,7 +52,7 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl
|
||||
|
||||
**去掉 backdrop,直接给 textarea 自己的文本上样式。** 这会消除分层,也一并消除这一整类失步。被否决是因为它不可实现:textarea 只渲染一段统一的文本,claim token 高亮、chip 与提示影子文本——backdrop 存在的理由——无从表达。为修滚动而失去它们,是拿一个有界缺陷换一次功能删除。
|
||||
|
||||
**用 `contenteditable` div 渲染草稿。** 一个元素、一个偏移、区间可上样式。因代价与缺陷严重不成比例被否决:`contenteditable` 会把输入法组合、撤销/重做、选区语义与粘贴规范化重新压回我们身上,而这些目前都由 textarea 加输入状态机处理,且状态机已经持有一份假定 textarea 取值语义的撤销日志。
|
||||
**用 `contenteditable` div 渲染草稿。** 一个元素、一个偏移、区间可上样式。因代价与缺陷严重不成比例被否决:`contenteditable` 会把输入法组合、撤销/重做、选区语义与粘贴规范化重新压回我们身上,而这些目前都由 textarea 加输入状态机处理,且状态机已经持有一份假定 textarea 值语义的撤销日志。
|
||||
|
||||
**给三层都加 `scrollbar-gutter: stable`。** 在 textarea 还是滚动者时试过并已回退:WebKit 只对 `overflow-y: auto` 生效、不对 `overflow: hidden` 生效,那 8px 的差距原样留着,却让每个 chromium 用户无条件损失 8px 文本列宽。现在已无意义——三层共享同一包含块,没有什么需要预留。
|
||||
|
||||
@@ -63,9 +63,9 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl
|
||||
- 光标不可能离开自己的字形。浏览器滚动的是同一个盒子,因此「textarea 把某一行放在哪」与「backdrop 把这一行画在哪」之间的距离在任何偏移下都是一个固定的行盒常量,手势进行中也不例外。场景测试度量的正是这个数。
|
||||
- 滚动条从 textarea 移到了滚动容器上——视觉位置相同,只是外移了一层。`.card` 的 l2 token 绑定仍会继承下去。
|
||||
- chip、claim token 高亮与文本引用标记在滚动时仍与其字形对齐,因为它们定位在 backdrop 内部、随之移动。除去掉哨兵之外,装饰扫描没有变化。
|
||||
- 在 Firefox 与 WebKit 上,点进一个草稿超过上限的 composer 现在还会把会话记录滚动到底部:光标的 scroll-into-view 会越过 composer 的滚动容器一路走到会话记录的滚动容器,而一个比自身盒子矮的 textarea 从不会引发这一步。chromium 不会。已实测,并试过 `overscroll-behavior: contain` 与 `contain: paint`,两者都拦不住这次上行——没有任何 CSS 能终止 scroll-into-view 的接力。接受:它滚向底部,而 composer 本来就在底部,而其替代方案是在每个引擎上都出现光标与文字明显分离。
|
||||
- 在 Firefox 与 WebKit 上,点进一个草稿超过上限的 composer 现在还会把会话 transcript(文本记录)滚动到底部:光标的 scroll-into-view 会越过 composer 的滚动容器一路走到 transcript 的滚动容器,而一个比自身盒子矮的 textarea 从不会引发这一步。chromium 不会。已实测,并试过 `overscroll-behavior: contain` 与 `contain: paint`,两者都拦不住这次上行——没有任何 CSS 能终止 scroll-into-view 的接力。接受:它滚向底部,而 composer 本来就在底部,而其替代方案是在每个引擎上都出现光标与文字明显分离。
|
||||
- 翻页与拖拽选区的行为未变,二者均做了新旧对照实测。`PageDown`/`PageUp` 本来就不会移动 textarea 的插入点——chromium 是滚动一页并保持 `selectionStart` 不变,新旧几何皆然,区别只在于滚的是哪个盒子。拖拽选区越过下边缘仍会自动滚动,且落点一致(chromium 628/628、firefox 625/620、WebKit 170/170——WebKit 自动滚动较慢,但改动前后一样慢)。
|
||||
- composer 自己发起的 `focus()` 全部加了 `preventScroll`——解锁/切会话的 effect,以及工具栏按钮上那个保持焦点的 mousedown——因此一次没有任何手势要求的聚焦,不会再通过更高的 textarea 的回视链把 transcript 挪走。抑制这条链之后,光标就回到了我们手上,而这在一条路径上确实要紧:composer 的 DOM 跨会话复用,因此切到更长的草稿时旧偏移会留着,而换值会把光标放到新草稿的末尾。三引擎实测,这会让光标落在停在 0 的盒子下方 940px 处;于是该 effect 会在自己的滚动容器里把它带回来,落点 625/628——正是旧几何靠浏览器达到的 628。mousedown 那条不需要回视:光标没动过,而下一次敲键会拿到浏览器原生的回视。另一个只负责回视的 effect 会处理渲染后才到达的非空草稿:`ConversationSession` 在自己的 mount effect 中注入持久化草稿,而该 effect 在本组件的 effect 之后运行,否则第一次回视会量到空镜像,且不会再为随后出现的草稿重跑。第二个 effect 从不聚焦,因此发送后清空或发送失败后恢复这类普通的空/非空转换不会从其他控件夺走焦点。
|
||||
- composer 自己发起的 `focus()` 全部加了 `preventScroll`——解锁/切会话的 effect,以及工具栏按钮上那个保持焦点的 mousedown——因此一次没有任何手势要求的聚焦,不会再通过更高的 textarea 的回视链把 transcript 挪走。抑制这条链之后,光标就回到了我们手上,而这在一条路径上确实要紧:composer 的 DOM 跨会话复用,因此切到更长的草稿时旧偏移会留着,而换值会把光标放到新草稿的末尾。三引擎实测,这会让光标落在停在 0 的盒子下方 940px 处;于是该 effect 会在自己的滚动容器里把它带回来,落点 625/628——正是旧几何靠浏览器达到的 628。mousedown 那条不需要回视:光标没动过,而下一次敲键会拿到浏览器原生的回视。另一个只负责回视的 effect 会处理渲染后才到达的非空草稿:`ConversationSession` 在自己的 mount effect 中注入持久化草稿,而该 effect 在本组件的 effect 之后运行,因此如果没有这个独立 effect,第一次回视会量到空镜像,且不会再为随后出现的草稿重跑。第二个 effect 从不聚焦,因此发送后清空或发送失败后恢复这类普通的空/非空转换不会从其他控件夺走焦点。
|
||||
- 撤销/重做可以在不恢复光标、也不回视的情况下改动草稿:状态机重放上一版草稿,DOM 选区停在浏览器钳位后的位置。这早于本次改动且未被改动——在此点名,是因为另外两处恢复都会回视,会让这处遗漏看起来像有意为之;真被报告时 helper 就在旁边。
|
||||
- 任何新增在 backdrop 旁边的层都属于滚动容器**内部**,并且必须与草稿等高,否则就会重新引入这一缺陷。这是 composer 长期存在的风险点:两层拆分对 chip 与高亮是承重的,因此耦合必须来自结构,而不是靠维护。
|
||||
|
||||
@@ -73,7 +73,7 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl
|
||||
|
||||
[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,backdrop 的文本现在就是草稿本身、不多不少,且渲染后才到达的持久化草稿会回视其光标,同时不从其他控件夺走焦点。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。
|
||||
|
||||
[composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物度量其余部分:全新工作区空会话的 composer 中一份 40 行草稿,零模型调用。每个度量都在光标自己的坐标系里读取——即 textarea 把第 n 行放在哪,含其自身偏移——再与 backdrop 同一行文本上的 DOM Range 相比,因为这个差值正是用户看到的东西。决定性的用例改变偏移,并**在本任务结束之前**重新读取该差值,也就是在任何 `scroll` 监听可能运行之前:单一滚动容器下为 0,镜像方案下则是整个增量。空洞性保护先断言草稿确实超过了带上限的盒子;其余用例分别覆盖高度上限、三层同一折行宽度、滚轮手势、以换行结尾的草稿,以及过去由 textarea 自身滚动承担的光标回视路径——滚离光标后输入,必须把滚动容器带回光标处。
|
||||
[composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物度量其余部分:全新工作区的空白 composer 中一份 40 行草稿,零模型调用。每个度量都在光标自己的坐标系里读取——即 textarea 把第 n 行放在哪,含其自身偏移——再与 backdrop 同一行文本上的 DOM Range 相比,因为这个差值正是用户看到的东西。决定性的用例改变偏移,并**在本任务结束之前**重新读取该差值,也就是在任何 `scroll` 监听可能运行之前:单一滚动容器下为 0,镜像方案下则是整个增量。空洞性保护先断言草稿确实超过了带上限的盒子;其余用例分别覆盖高度上限、三层同一折行宽度、滚轮手势、以换行结尾的草稿,以及过去由 textarea 自身滚动承担的光标回视路径——滚离光标后输入,必须把滚动容器带回光标处。
|
||||
|
||||
另有一个用例端到端覆盖粘贴路径:短草稿、光标停在末尾,然后派发一个携带真实剪贴板数据的 `paste` 事件——与 Cmd-V 送达的是同一个事件,走同一个处理器——再检查偏移与所粘内容的最后一行。它等待的是偏移而不是「草稿是否溢出」,因为恢复发生在状态机提交草稿之后的下一帧;没有这次回视的构建会卡在这个等待上失败。
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.md
|
||||
2026-07-31-english-compaction-checkpoints.md: dc95ed187a6ba5b86800f06ebefb2776995f9421
|
||||
2026-07-31-english-compaction-checkpoints.zh.md: 7cd4179430ed775f3807af5a02e81838e495c81e
|
||||
2026-07-31-english-compaction-checkpoints.zh.md: 5b89dca449309abaefe3143f66a2878e2d9eea47
|
||||
|
||||
@@ -12,14 +12,14 @@ Status: implemented
|
||||
|
||||
`COMPACTION_INSTRUCTION` 要求生成英语的内部工程检查点。它要求模型在必要时翻译叙述性源材料,同时保留精确的字面量;这包括路径、命令、错误、标识符、签名,以及精确性重要时的引用措辞。检查点的标题及简洁的工程项目符号仍沿用既有的结构化格式。
|
||||
|
||||
这项要求被整合到尾部压缩指令的第一句话中。回放的系统提示词、工具和对话历史与已路由请求保持字节级一致,因此该变更保留 [compaction summary prefix-cache note](2026-07-21-compaction-summary-prefix-cache-reuse.md) 所确立的前缀缓存复用。
|
||||
这项要求被整合到尾部压缩指令的第一句话中。回放的系统提示词、工具和对话历史与已路由请求保持字节级一致,因此该变更保留 [压缩摘要前缀缓存说明](2026-07-21-compaction-summary-prefix-cache-reuse.md) 所确立的前缀缓存复用。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **让回放的对话决定检查点语言**——不采纳:检查点是持久的提示词前缀,保留短暂的对话文体可能在后续压缩中放大这种影响。
|
||||
- **约束对话模型的语言**——不采纳:该策略针对内部检查点,而不是用户可见的对话;对整个对话施加规则会不必要地改变正常交互。
|
||||
- **要求仅输出 ASCII**——不采纳:ASCII 是字符集约束,而非工程文体约束,并会不必要地扭曲合法的字面量和技术材料。
|
||||
- **在末尾追加一句独立的仅限英语要求**——不采纳:在指令开头的输出契约中说明该要求更简洁,也直接将其与所请求的检查点绑定。
|
||||
- **在末尾追加一句独立的仅限英语要求**——不采纳:在指令开头的输出约定中说明该要求更简洁,也直接将其与所请求的检查点绑定。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md
|
||||
2026-07-31-fail-loud-releases-the-terminal.md: 2a6e7fcbbdd5d35bcf70dee09fdb9e5592486b78
|
||||
2026-07-31-fail-loud-releases-the-terminal.zh.md: f75c21cf79b241e6714c10ec7df9ac25f3d978b4
|
||||
2026-07-31-fail-loud-releases-the-terminal.md: d5a97d73ec60897a3a40f3c99417e43de190af9c
|
||||
2026-07-31-fail-loud-releases-the-terminal.zh.md: 271769d32ea1dd66c698a65b35461f51042b7efb
|
||||
|
||||
@@ -52,8 +52,8 @@ The guarantee belongs to whichever bin owns the terminal: a surface that grabs t
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS`, and a burst of rejections reports only the first while the release still completes.
|
||||
`packages/boot/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS`, and a burst of rejections reports only the first while the release still completes.
|
||||
|
||||
Those fake-process tests cannot observe the two failure modes that matter most — process exit code with a real event loop, and terminal state after exit — so the regression lives in `apps/cli/tests/tui-keyless-smoke.e2e.ts`. It boots the shipped tree in a real PTY over `fixtures/tui-invalid-provider.cordis.yml` (a list-shaped `providers`, the mistake users actually make), expects exit 1, and asserts the captured bytes contain both the labelled boot rejection (`dsh: plugin tree failed to load:`) and `ESC[?2004l`. The same case pins the boot path end to end: it caught the [HMR initial-scan boot deadlock](2026-08-03-hmr-initial-scan-boot-deadlock.md) that silently exited 13 with the terminal stranded.
|
||||
|
||||
Testing policy requires a PTY case whenever terminal teardown changes, and this is it. The `/exit` path keeps its existing assertion that the same reset appears on a clean exit.
|
||||
The `/exit` path keeps its existing assertion that the same reset appears on a clean exit.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Agent Note:fail-loud 在退出前释放终端
|
||||
# Agent Note: fail-loud 在退出前释放终端
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-31-fail-loud-releases-the-terminal.md) | 中文
|
||||
|
||||
## Problem
|
||||
## 问题
|
||||
|
||||
配置校验失败的 `dsh` 启动会打印诊断信息,然后把用户丢回一个损坏的 shell:输入不可见,下一条命令还会被残留文本弄乱:
|
||||
|
||||
@@ -17,24 +17,24 @@ zsh: command not found: 4cecho
|
||||
|
||||
Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。
|
||||
|
||||
在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行释放部分构建的上下文;release 回调仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节释放这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
|
||||
在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行 dispose(资源释放)部分构建的上下文;release 回调仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
|
||||
|
||||
`/exit` 路径从不受影响,因为它会释放整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。
|
||||
`/exit` 路径从不受影响,因为它会 dispose 整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。
|
||||
|
||||
## Decision
|
||||
## 决策
|
||||
|
||||
`installFailLoud` 新增可选的 `release` 拆卸回调,在诊断信息与退出之间被等待:
|
||||
|
||||
- 诊断信息在 release **之前**写出,因此卡住或失败的 disposer 无法吞掉失败原因。
|
||||
- 使用闩锁(latch)而非卸载监听器,来保证被报告的始终是第一个 rejection。若在拆卸期间移除监听器,第二个并发 rejection 就会变成未捕获错误,Node 会在拆卸中途杀死进程——恰好残留下本次要恢复的终端状态。后续 rejection(包括 release 自身的)都会落入已挂起的退出流程。
|
||||
- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。该定时器保持 **referenced**:一旦 `unref()`,Node 就会在事件循环清空后、恰恰在报告这次失败时以 0 退出,因为 `unhandledRejection` 监听器抑制了默认的致命退出。
|
||||
- 不传 `release` 时行为与此前完全一致,因此 ACP、JSON-RPC 和各 demo bin 均无变化。
|
||||
- 不传 `release` 时行为与此前完全一致,因此 ACP(Agent Client Protocol)、JSON-RPC 和各 demo bin 均无变化。
|
||||
|
||||
`dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。
|
||||
|
||||
启动器在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值。rejection 到达时 `boot()` 尚未结算,因此在 `await` 之后赋值的 `app.current` 恰好在回调需要它的那一刻仍是 `undefined`。`prepare` 在 Loader 安装之后、任何配置树条目挂载之前运行,覆盖了条目可能 rejection 的整个窗口。
|
||||
|
||||
## Alternatives considered
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在 fail-loud 处理函数里直接重置终端**(写 `ESC [ ? 2004 l`、弹出键盘协议、清除 raw 模式)。这会在一个并不拥有终端的包里重复 pi-tui 的拆卸逻辑,并随 pi-tui 启动序列的变化而漂移。它同样无法吸收尚未返回的 Device Attributes 响应——而这正是弄乱下一个提示符的原因,只有在 stdin 仍处于 raw 模式时排空它才能解决。
|
||||
|
||||
@@ -44,16 +44,16 @@ Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动
|
||||
|
||||
**调整配置顺序,让 `llm-pi-ai` 先于 `ui-tui` 挂载。** 顺序并不是 Loader 提供的保证,而且未来任何条目都可能在 TUI 挂载之后失败。
|
||||
|
||||
## Consequences
|
||||
## 后果
|
||||
|
||||
启动失败现在会在退出前多付出一次树释放的代价(上限 2 秒),退出码仍为 1。作为交换,配置错误的 `dsh` 会交还一个可用的 shell,而不是需要 `stty sane` 或 `reset` 才能恢复的终端。
|
||||
|
||||
这项保证属于**拥有终端的那个 bin**:任何抢占终端状态却不传 `release` 的界面都会重新引入该缺陷。`installFailLoud` 自身无法察觉这一点,因为它看不到已挂载的插件对进程做了什么。
|
||||
|
||||
## Testing
|
||||
## 测试
|
||||
|
||||
`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1;永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及一连串 rejection 只报告第一个,同时 release 仍能跑完。
|
||||
`packages/boot/app-boot/tests/app-boot.spec.ts` 覆盖 release 约定:退出提交前会等待该回调;回调 rejection 时仍退出 1;永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及一连串 rejection 只报告第一个,同时 release 仍能跑完。
|
||||
|
||||
这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `apps/cli/tests/tui-keyless-smoke.e2e.ts`。它在真实 PTY 中以 `fixtures/tui-invalid-provider.cordis.yml`(`providers` 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1,并断言捕获到的字节流同时包含带标签的启动 rejection(`dsh: plugin tree failed to load:`)与 `ESC[?2004l`。同一用例端到端钉住了启动路径:正是它发现了以 13 静默退出、终端状态被残留的 [HMR 初始扫描启动死锁](2026-08-03-hmr-initial-scan-boot-deadlock.md)。
|
||||
这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `apps/cli/tests/tui-keyless-smoke.e2e.ts`。它在真实 PTY 中以 `fixtures/tui-invalid-provider.cordis.yml`(`providers` 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1,并断言捕获到的字节流同时包含带标签的启动 rejection(`dsh: plugin tree failed to load:`)与 `ESC[?2004l`。同一用例端到端钉住了启动路径:正是它发现了以 13 静默退出、终端状态未被恢复的 [HMR(热模块替换)初始扫描启动死锁](2026-08-03-hmr-initial-scan-boot-deadlock.md)。
|
||||
|
||||
测试规范要求:只要改动终端拆卸,就必须有 PTY 用例——这就是它。`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。
|
||||
`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md
|
||||
2026-07-31-fork-anchor-floors-to-event-seq.md: 2cc8d66942442aca69c930be880cc41d56bced13
|
||||
2026-07-31-fork-anchor-floors-to-event-seq.zh.md: 9f443f3d5165c01c6585d33bd6a37e90e9b3bf95
|
||||
2026-07-31-fork-anchor-floors-to-event-seq.zh.md: 75fc1885f344e88c9ded575bc4aea58562e55673
|
||||
|
||||
@@ -18,11 +18,11 @@ host 的切分规则从来不是障碍。被中止的轮次会记录一条 reaso
|
||||
|
||||
向下取整落在锚点自身所在的轮次内,不会回退:每一轮都以 `turn/start` 开头,所以 `turnEnd.seq - 1` 不可能是上一轮的 `turn/end`。host 随后按「首个位于锚点或其之后的 `turn/end`」收口,命中的正是读者点击的那一轮,与消息级 fork 按钮在已完成轮次上一贯承诺的整轮语义一致。
|
||||
|
||||
apiproxy 的 fork 用例固定了 host 这一侧的契约:落在被中止轮次内的取整锚点会切穿该轮,并把它种进子会话。
|
||||
apiproxy 的 fork 用例固定了 host 这一侧的约定:落在被中止轮次内的取整锚点会切穿该轮,并把它种进子会话。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**让 wire 接受分数 `atSeq`。** 否决:host 契约要的是事件 seq,而不是连续坐标上的某个位置;分数形式只是某一个客户端的渲染约定,一旦放行,`atSeq` 会成为所有携带 seq 的载荷中唯一容忍非整数的字段。
|
||||
**让 wire 接受分数 `atSeq`。** 否决:host 约定要的是事件 seq,而不是连续坐标上的某个位置;分数形式只是某一个客户端的渲染约定,一旦放行,`atSeq` 会成为所有携带 seq 的载荷中唯一容忍非整数的字段。
|
||||
|
||||
**在已中断的消息上隐藏 fork 按钮。** 否决:从读者主动叫停的那一轮分叉,恰恰是最需要 fork 的场景之一,而 host 侧这个能力一直是好的。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md
|
||||
2026-07-31-resume-selector-batch-projection.md: 39146527f13b20813bb6f5d5f1349ecab5724662
|
||||
2026-07-31-resume-selector-batch-projection.zh.md: 3f824a3063849306e5c1c21699f6a19eefcfefe5
|
||||
2026-07-31-resume-selector-batch-projection.md: 5b0b33c34137efb95f31e07e7fce7622aeb2dbce
|
||||
2026-07-31-resume-selector-batch-projection.zh.md: a12df91ba75b7bdc2d9f48dc633ad1689d163180
|
||||
|
||||
@@ -22,7 +22,7 @@ No session-query or session-persistence surface changed. The shipped TUI composi
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep per-row route/turn/goal columns via a generic batch projection (`projectSessions`).** Implemented first, then rejected: it still decompressed and parsed every log on every `/resume`, so browsing cost stayed O(total log bytes), and it grew the session-query public API for one consumer. The public seam was reverted; `readTitleSnapshots` keeps using the internal `projectMany` unchanged.
|
||||
**Keep per-row route/turn/goal columns via a generic batch projection (`projectSessions`).** Implemented first, then rejected: it still decompressed and parsed every log on every `/resume`, so browsing cost stayed O(total log bytes), and it grew the session-query public API for one consumer. The public contract was reverted; `readTitleSnapshots` keeps using the internal `projectMany` unchanged.
|
||||
|
||||
**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominated on large logs. The redundant pre-listing in `load()` remains a candidate cleanup with error-semantics implications.
|
||||
|
||||
|
||||
@@ -22,13 +22,13 @@ session-query 与 session-persistence 的任何表面都未改变。随附的 TU
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**通过通用批量投影(`projectSessions`)保留每行的路由/轮次/目标列。** 先实现后否决:它仍在每次 `/resume` 时解压并解析全部日志,浏览开销依旧是 O(日志总字节数),且为单一消费者扩大了 session-query 公开 API。该公开 seam 已回退;`readTitleSnapshots` 继续使用内部 `projectMany`,保持不变。
|
||||
**通过通用批量投影(`projectSessions`)保留每行的路由/轮次/目标列。** 先实现后否决:它仍在每次 `/resume` 时解压并解析全部日志,浏览开销依旧是 O(日志总字节数),且为单一消费者扩大了 session-query 公开 API。该公开约定已回退;`readTitleSnapshots` 继续使用内部 `projectMany`,保持不变。
|
||||
|
||||
**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。
|
||||
|
||||
**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化契约、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。
|
||||
**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化约定、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。
|
||||
|
||||
**专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效契约(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。
|
||||
**专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效约定(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md
|
||||
2026-07-31-web-stop-preserves-queue.md: b8bba45009c126495a8b03ee36d6eb2cccac9a93
|
||||
2026-07-31-web-stop-preserves-queue.zh.md: 0e1fffae8819bea81b91fae10c76dbadc78474fe
|
||||
2026-07-31-web-stop-preserves-queue.zh.md: af9c114fd06452e58322f2554a0f2ccd861c81c9
|
||||
|
||||
@@ -16,7 +16,7 @@ Web 停止按钮调用 `session.cancel`,后者映射到广义 `agent.cancel({
|
||||
|
||||
AgentLoop 不会启动并发的替代轮次。它会关闭并 flush 被中断的轮次,达到取消的完全停稳,然后通过现有 FIFO 驱动器认领下一个可唤醒的 queued 入队项。该认领会发出 `agent/inbox/dequeue`,因此 Host 的权威 `session/queue` 快照会退役已认领行,并使剩余队尾保持可见。浏览器既不重发,也不提升任何行。忽略取消的工作会延迟这一交接,直到该工作结算。
|
||||
|
||||
该映射只更改 Web 客户端使用的 Host `session.cancel` 端点。`Agent.cancel()` 默认契约仍为广义取消,ACP 和 TUI 保留既有取消策略,`AgentHandle.dispose()` 在拆卸期间仍会清除待处理工作。移除 Queue 行仍是用于丢弃单个待处理入队项的显式 Web 操作。
|
||||
该映射只更改 Web 客户端使用的 Host `session.cancel` 端点。`Agent.cancel()` 默认约定仍为广义取消,ACP 和 TUI 保留既有取消策略,`AgentHandle.dispose()` 在拆卸期间仍会清除待处理工作。移除 Queue 行仍是用于丢弃单个待处理入队项的显式 Web 操作。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-01-ask-user-delegated-caller-guard.md
|
||||
2026-08-01-ask-user-delegated-caller-guard.md: 0350a7383f7ace6ecd742c98db18f8b977720cf9
|
||||
2026-08-01-ask-user-delegated-caller-guard.zh.md: 9c19722feb4586e07845fd20279574301267542d
|
||||
2026-08-01-ask-user-delegated-caller-guard.zh.md: 811a550bacc6f9019fdd08c7b555bc11753a9477
|
||||
|
||||
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
以运行时所有权为权限依据。携带谱系的会话在无所有者的情况下恢复时就是运行时根,可以提问;存活子级即使持久化 `delegationDepth` 为零,仍无资格提问。不带 agent 的程序化调用继续沿用现有提供方路径。
|
||||
|
||||
共享失败文本与具体消费方无关,并给出可执行指引:子级把尚未解决的问题或决策写入最终结果。委托契约本就会把该结果传给父级,父级可据此决定是否询问人类。服务和子级都不会宣称存在实际上并不存在的向上消息传递或回答转发能力。
|
||||
共享失败文本与具体消费方无关,并给出可执行指引:子级把尚未解决的问题或决策写入最终结果。委托约定本就会把该结果传给父级,父级可据此决定是否询问人类。服务和子级都不会宣称存在实际上并不存在的向上消息传递或回答转发能力。
|
||||
|
||||
该安全边界与浏览器的 composer 选举相互独立。提议的[语义 composer 阶段](../../proposed/architecture/2026-08-08-semantic-composer-chain-phases.md)解决已有待处理交互与只读 subagent 界面的排序方式;它不会削弱此运行时守卫。
|
||||
|
||||
@@ -34,6 +34,6 @@ Status: implemented
|
||||
|
||||
运行时中归属于其他 agent 的子级调用会以稳定的结构化错误快速失败,而不是挂起。注册表中的确切存活根和不带 agent 的程序化调用仍有资格提问,包括带有历史子级谱系的已恢复会话。`ask_user_question` 与 `exit_plan_mode` 会收到相同的中性纠正指引,而其模型可见 schema 和系统提示词前缀保持不变;只有追加的错误结果发生变化,因此现有 KV Cache 前缀仍可复用。
|
||||
|
||||
## Testing
|
||||
## 测试
|
||||
|
||||
服务测试覆盖持久化深度为零的存活子级、深度为一的已恢复运行时根、缺失注册表、仅 id 相同的陈旧对象,以及每次拒绝都不调用提供方。工具与 plan-mode 测试证明两个消费方都会呈现中性的 `DELEGATED_CALLER` 结果,且绝不触达提供方。无密钥组装快照委托一个尝试调用 `ask_user_question` 的子级,固定其错误工具结果和最终交接,并证明父级可以完成,而不是一直等待回答。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md
|
||||
2026-08-02-goal-round-wrapup-message.md: c6bc3d5912b0789efde55880c2be892e98e34a5b
|
||||
2026-08-02-goal-round-wrapup-message.zh.md: 0a504b4dcc61feb932775b3d9ffd8424f3b0597d
|
||||
2026-08-02-goal-round-wrapup-message.zh.md: f223516e1255f2dafe026a48d1faafa838af6cb8
|
||||
|
||||
@@ -18,13 +18,13 @@ Goal Round 的 `complete` 或 `blocked` 成功不再调用 `concludeTurn()`。
|
||||
|
||||
## 验证
|
||||
|
||||
`tool-goal` 包测试钉住两个终态 action 注入的上下文(source、标签、objective、禁止再调工具条款)与不存在的 `concludesTurn`,以及人类直接 pause 与 complete 的不注入路径,文件覆盖率 100%。`llm-replay` 单元测试钉住占位符契约:最后一次匹配取胜的捕获、无捕获组时整体匹配回退,以及未匹配、非法、未闭合模式的明确报错。新增 keyless ACP 快照 `goal-wrapup` 驱动成品应用走完 create → 第一轮 → 自主 complete,并在持久会话日志与 ACP stdout 流中同时断言 plugin 来源的收尾注入、同轮内的收尾 assistant 消息与 `completed` 轮次结束。
|
||||
`tool-goal` 包测试钉住两个终态 action 注入的上下文(source、标签、objective、禁止再调工具条款)与不存在的 `concludesTurn`,以及人类直接 pause 与 complete 的不注入路径,文件覆盖率 100%。`llm-replay` 单元测试钉住占位符约定:最后一次匹配取胜的捕获、无捕获组时整体匹配回退,以及未匹配、非法、未闭合模式的明确报错。新增 keyless ACP 快照 `goal-wrapup` 驱动成品应用走完 create → 第一轮 → 自主 complete,并在持久会话日志与 ACP stdout 流中同时断言 plugin 来源的收尾注入、同轮内的收尾 assistant 消息与 `completed` 轮次结束。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **在 `update_goal` 的 UI 卡片上展示完成文本** — 拒绝:`complete` 如今不携带任何自由文本;新增 `summary` 参数会让面向用户的汇报走工具参数通道,而且依然砍掉了模型在结果之后的自然发言。
|
||||
- **保留 `concludeTurn()` 并新增“再多一步纯文本”的 loop 原语** — 拒绝:为常规停止路径已经能提供的行为(只要没有结果终结轮次)增加新的 `agent-loop` 机制。
|
||||
- **把指令写进工具结果内容** — 拒绝:goal 工具的规范输出是被程序化消费的紧凑 JSON;在其中混入散文指令会把模型侧契约和工具的可回放值搅在一起。
|
||||
- **把指令写进工具结果内容** — 拒绝:goal 工具的规范输出是被程序化消费的紧凑 JSON;在其中混入散文指令会把模型侧约定和工具的可回放值搅在一起。
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md
|
||||
2026-08-02-message-fork-actions-require-completed-turn-tail.md: abdcbc79948c67619bb70a8a87741046f65b8838
|
||||
2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: a93b572c6db3c76ce3869747fd9a7660bf3ea395
|
||||
2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: 7df9b04e982e9d290030cb9be6c27bb68541aaeb
|
||||
|
||||
@@ -12,7 +12,7 @@ Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assist
|
||||
|
||||
`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息、持久 steering(中途引导)消息或含内容的 assistant 消息时才启用分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作会保持不可用。不可用的控件仍然可见、可聚焦、可悬停;`aria-disabled`、tooltip 与 `aria-describedby` 会说明已完成尾部这一要求,且不会发送 Host 请求。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。
|
||||
|
||||
本资格判定中消息气泡的那一半已被 [user 气泡分支移除决策](../simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)取代:user 与 steering 气泡不再渲染该控件,因此只有内容 assistant 尾部可以 fork;assistant 侧门禁及其可见但不可用的呈现保持有效。
|
||||
本资格判定中消息气泡的那一半已被 [user 气泡分支移除决策](../simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)取代:user 与 steering 气泡不再渲染该控件,因此只有含内容的 assistant 尾部可以 fork;assistant 侧门禁及其可见但不可用的呈现保持有效。
|
||||
|
||||
本决策收紧了较早的 [Web 会话 fork 操作决策](../feature/2026-07-27-web-session-fork-actions.md)所定义的消息资格。Session 行 fork 仍选择最新的已完成轮次;符合条件的消息操作仍通过共享 client 运行时操作传递其事件 seq。
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md
|
||||
2026-08-02-todo-first-composer-context-order.md: 26962426c763c6911fc61acab09cdb96725922b1
|
||||
2026-08-02-todo-first-composer-context-order.zh.md: a81401ffeb8df5855b584a5727e24067d9fb1e70
|
||||
2026-08-02-todo-first-composer-context-order.zh.md: 2bd09a95832b22afc919a6f2d18bef94d4209c40
|
||||
|
||||
@@ -14,7 +14,7 @@ composer 上下文堆栈将 Goal 渲染在 Todo 之前,但 Harness 设计稿
|
||||
|
||||
Todo、Goal 与可见的 Queue 面板共用 800px composer 宽度上限内的 752px 卡片列。Queue 保留 776px 包装层,并在两侧各留 12px 透明内缩,因为该包装层负责与 composer 重叠。Todo 是独立卡片,而非包装层,因此其响应式宽度和最大宽度都会直接扣除两层内缩。Goal 使用相同的响应式卡片列,并将内层横条的宽度上限设为 752px,从而在低于桌面宽度上限时也保持边缘对齐。
|
||||
|
||||
[composer 堆栈契约](2026-07-30-composer-context-stack-order.md)继续规定卡片间距,以及仅限 Queue 与 composer 重叠。本决策只取代该记录中 Goal 优先的顺序。
|
||||
[composer 堆栈约定](2026-07-30-composer-context-stack-order.md)继续规定卡片间距,以及仅限 Queue 与 composer 重叠。本决策只取代该记录中 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/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md
|
||||
2026-08-03-cli-signal-shutdown-escalation.md: 7c9715c37ee57be9fa0f67af0c19f0bfa84845da
|
||||
2026-08-03-cli-signal-shutdown-escalation.zh.md: f3485edc9e453c0b774f442bfce6d678d63f2224
|
||||
2026-08-03-cli-signal-shutdown-escalation.md: c8aac6e2be927bd1f4a445c00e0aaa870b10a465
|
||||
2026-08-03-cli-signal-shutdown-escalation.zh.md: 14e2149eb5153b3743e188c6b233d000b36d60db
|
||||
|
||||
@@ -49,4 +49,4 @@ The controller is launcher infrastructure rather than a Cordis plugin: it makes
|
||||
|
||||
`apps/cli/tests/headless-shutdown.e2e.ts` boots the real shipped Web/headless Loader tree in a PTY with a test-only plugin whose disposer announces entry and never settles. The test sends SIGINT after the observation URL, waits for proof that disposal started, sends SIGINT again, and requires exit 130. The source/artifact launch resolver keeps the same regression on both execution planes. This PTY case covers the user-visible process state; no model-output snapshot changes.
|
||||
|
||||
`packages/telemetry/session-telemetry-otel/tests/otel.spec.ts` holds a real OTLP request open after timer export begins and pins that Cordis disposal returns at `shutdownTimeoutMillis`, despite the SDK's `forceFlush()` remaining pending. The collector is then released so the still-observed provider Promise settles cleanly.
|
||||
`packages/session/session-telemetry-otel/tests/otel.spec.ts` holds a real OTLP request open after timer export begins and pins that Cordis disposal returns at `shutdownTimeoutMillis`, despite the SDK's `forceFlush()` remaining pending. The collector is then released so the still-observed provider Promise settles cleanly.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Agent Note:Web 与 headless 的有界信号关闭和重复信号强制退出
|
||||
# Agent Note: Web 与 headless 的有界信号关闭和重复信号强制退出
|
||||
|
||||
状态:已实现
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-03-cli-signal-shutdown-escalation.md) | 中文
|
||||
|
||||
@@ -39,7 +39,7 @@ headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API
|
||||
|
||||
## 后果
|
||||
|
||||
健康的退出流程仍会对整棵 Cordis 插件树执行 dispose。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒,再次收到信号则立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭契约已经失败,或用户明确要求强制退出时,才会有意接受这一结果。
|
||||
健康的退出流程仍会对整棵 Cordis 插件树执行 dispose。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒,再次收到信号则立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭约定已经失败,或用户明确要求强制退出时,才会有意接受这一结果。
|
||||
|
||||
该控制器属于启动器基础设施,而不是 Cordis 插件:它不会声称 dispose 已经完成,也不会削弱普通 disposer 必须达到完全停稳状态的生命周期规则。
|
||||
|
||||
@@ -49,4 +49,4 @@ headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API
|
||||
|
||||
`apps/cli/tests/headless-shutdown.e2e.ts` 在 PTY 中启动真实交付的 Web/headless Loader 插件树,并挂载一个仅用于测试的插件;该插件的 disposer 会声明已经进入清理流程,但永不结算。测试在观察地址出现后发送 SIGINT,等待 dispose 已启动的证据,再次发送 SIGINT,并要求进程以 130 退出。源码/产物启动解析器使两个执行平面都覆盖同一项回归。该 PTY 用例覆盖用户可见的进程状态;模型输出快照没有变化。
|
||||
|
||||
`packages/telemetry/session-telemetry-otel/tests/otel.spec.ts` 在定时器导出开始后保持一条真实 OTLP 请求打开,并固定以下行为:即使 SDK 的 `forceFlush()` 仍待结算,Cordis dispose 也会在 `shutdownTimeoutMillis` 到期时返回。随后测试释放 collector,使仍受观察的提供方 Promise 干净结算。
|
||||
`packages/session/session-telemetry-otel/tests/otel.spec.ts` 在定时器导出开始后保持一条真实 OTLP 请求打开,并固定以下行为:即使 SDK 的 `forceFlush()` 仍待结算,Cordis dispose 也会在 `shutdownTimeoutMillis` 到期时返回。随后测试释放 collector,使仍受观察的提供方 Promise 干净结算。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md
|
||||
2026-08-03-hmr-initial-scan-boot-deadlock.md: 4b3e259c216d258c321ab06c41225b33ed240d19
|
||||
2026-08-03-hmr-initial-scan-boot-deadlock.zh.md: ce1bc8396ac6e7fb6ecb1647fe2b29cdc788c7e1
|
||||
2026-08-03-hmr-initial-scan-boot-deadlock.md: 1a70fbafc74a1bf7024c675ddad82acefc995b72
|
||||
2026-08-03-hmr-initial-scan-boot-deadlock.zh.md: 7123e423aab1293d138a1c24cb0dded00a83c89a
|
||||
|
||||
@@ -38,4 +38,4 @@ One latent gap remains: a config edit made during a *failing* initial apply can
|
||||
|
||||
## Testing
|
||||
|
||||
The `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts` pins the end-to-end contract: exit 1, the labelled `dsh: plugin tree failed to load:` diagnostic naming `$.providers`, and the bracketed-paste reset proving the tree was disposed. Before this fix the same case observed exit 13 with no diagnostic. Reload behavior stays covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/ui/app-boot/tests/hmr-config.spec.ts`.
|
||||
The `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts` pins the end-to-end contract: exit 1, the labelled `dsh: plugin tree failed to load:` diagnostic naming `$.providers`, and the bracketed-paste reset proving the tree was disposed. Before this fix the same case observed exit 13 with no diagnostic. Reload behavior stays covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/boot/app-boot/tests/hmr-config.spec.ts`.
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- `include/src/index.ts` 将每次子树变更——首次 apply、refresh、`internal/update` 补丁重应用——汇入每个 Include 一条的 promise 队列。group 的事务化 `update` 不可重入,因此序列化是正确性要求,而不是吞吐取舍。`refresh()` 也在队列内读取文件,使其内容变更判断与前一任务提交后的状态比较。
|
||||
- `hmr/src/index.ts` 给主 watcher 传入 `ignoreInitial: true`。初始扫描只会重新宣告启动刚刚消费过的文件;抑制它同时消除了启动期 refresh 和对已加载模块的多余 `add` 事件。`registerConfig()` 保留自己 `ignoreInitial: false` 的 watcher,因为注册时已存在的个人配置必须恰好应用一次。
|
||||
|
||||
两者齐备后,失败的启动走上预期路径:唯一一次 apply 失败,回滚释放整棵树(执行 TUI 自身的 shutdown、恢复终端),`loader.create` reject,`boot()` 重新抛出带标签的诊断并以 1 退出。
|
||||
两者齐备后,失败的启动走上预期路径:唯一一次 apply 失败,回滚并 dispose(资源释放)整棵树(执行 TUI 自身的 shutdown、恢复终端),`loader.create` reject,`boot()` 重新抛出带标签的诊断并以 1 退出。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -38,4 +38,4 @@
|
||||
|
||||
## 测试
|
||||
|
||||
`apps/cli/tests/tui-keyless-smoke.e2e.ts` 中 `dsh` 无效 provider 的 PTY 用例钉住了端到端契约:以 1 退出、带标签的 `dsh: plugin tree failed to load:` 诊断指明 `$.providers`、以及证明整棵树已被释放的 bracketed-paste 复位序列。此修复之前,同一用例观察到的是无诊断的 exit 13。重载行为仍由 `packages/ui/app-boot/tests/config-reload.spec.ts` 与 `packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖。
|
||||
`apps/cli/tests/tui-keyless-smoke.e2e.ts` 中 `dsh` 无效 provider 的 PTY 用例钉住了端到端约定:以 1 退出、带标签的 `dsh: plugin tree failed to load:` 诊断指明 `$.providers`、以及证明整棵树已被释放的 bracketed-paste 复位序列。此修复之前,同一用例观察到的是无诊断的 exit 13。重载行为仍由 `packages/boot/app-boot/tests/config-reload.spec.ts` 与 `packages/boot/app-boot/tests/hmr-config.spec.ts` 覆盖。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md
|
||||
2026-08-04-composer-tab-gutter-reservation.md: 3b28c35c1f11676e41cabde76d1b0d16c688f034
|
||||
2026-08-04-composer-tab-gutter-reservation.zh.md: 26e8b6bff73a01e6518f3918d201330c1d029876
|
||||
2026-08-04-composer-tab-gutter-reservation.zh.md: c357dd06c52a834c18d2e8a25246d4ad003db548
|
||||
|
||||
@@ -10,15 +10,15 @@ composer 座位在组件树中只有一个节点、一个位置,但它究竟
|
||||
|
||||
在 Chat 中它是会话列滚动容器(`[data-conversation-scroll]`)的 sticky **子元素**,因而依附于该容器的 content box——而占布局宽度的滚动条会把这个盒子收窄一条滚动条的宽度。声明了 `data-conversation-composer-overlay` 的视图(Trajectory 即是其一)会把会话列的滚动搬进视图自身:以该属性为条件的那条分支把滚动容器留作 `overflow: hidden`,并把座位改为绝对定位——对齐的是 padding box,而滚动条从不收窄这个盒子。
|
||||
|
||||
于是只要对话记录超出一屏——任何带历史的会话的常态——两个标签页就恰好相差一条滚动条的宽度。输入卡片是居中的,因此在 8px 的滚动条下切换标签页会让它横向移动 4px,而右侧留白整整变化 8px。同一位移也出现在 Chat 内部:对话增长到开始滚动的那一刻,以及从 hero 态进入第一个可滚动轮次时。
|
||||
于是只要 transcript(文本记录)超出一屏——任何带历史的会话的常态——两个标签页就恰好相差一条滚动条的宽度。输入卡片是居中的,因此在 8px 的滚动条下切换标签页会让它横向移动 4px,而右侧留白整整变化 8px。同一位移也出现在 Chat 内部:transcript 增长到开始滚动的那一刻,以及从 hero 态进入第一个可滚动轮次时。
|
||||
|
||||
## 决策
|
||||
|
||||
`.scrollBody` 无条件声明 `scrollbar-gutter: stable`,overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden`。
|
||||
|
||||
这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
|
||||
这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而「溢出与否」恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
|
||||
|
||||
overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动容器记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。
|
||||
overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。
|
||||
|
||||
横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。
|
||||
|
||||
@@ -30,21 +30,21 @@ overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图
|
||||
|
||||
**保留 `overflow: hidden`,只加 `scrollbar-gutter: stable`。** 单行版本。它能在浏览器车道所用的引擎上修掉可见症状,却把症状原封不动留在 Safari 上,而且任何测试都不会失败——这正是改动的后一半所要防的失效模式。
|
||||
|
||||
**让 Chat 的 composer 座位也移出滚动容器,使 overlay 的几何成为唯一的几何。** 这是从根上删掉差异,而不是调和它,代价是放弃一项刻意的性质:sticky 座位位于滚动流之内,因此在 composer 上滚轮会带动对话记录([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)),其上方的渐隐遮罩也由座位自身的背景绘制。两者都是有主、有覆盖的既有行为;为了消除 8px 的不对称而重建它们,是更大的改动而非更小的。
|
||||
**让 Chat 的 composer 座位也移出滚动容器,使 overlay 的几何成为唯一的几何。** 这是从根上删掉差异,而不是调和它,代价是放弃一项刻意的性质:sticky 座位位于滚动流之内,因此在 composer 上滚轮会带动对话记录([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)),其上方的渐隐遮罩也由座位自身的背景绘制。两者都是已有明确归属且各有测试覆盖的行为;为了消除 8px 的不对称而重建它们,是更大的改动而非更小的。
|
||||
|
||||
**给会话列加上一条滚动条宽度的内边距,而不是预留滚动条槽。** 内边距无论是否存在滚动条都会生效,因此在每种状态下都无条件付出这份宽度,而且它把一个由引擎在布局期决定的值钉死在样式表里。否决理由与侧边栏列表当初否决它时相同。
|
||||
|
||||
## 后果
|
||||
|
||||
- Chat 的内容列永久变窄 8px——hero 态与对话记录尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。
|
||||
- 一条声明覆盖三种切换,因为这三者本就是同一个差异:Chat ↔ Trajectory、Chat 内部的短对话 ↔ 可滚动对话,以及 hero ↔ 第一个可滚动轮次。
|
||||
- Chat 的内容列永久变窄 8px——hero 态与 transcript 尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。
|
||||
- 一条声明覆盖三种切换,因为这三者本就是同一个差异:Chat ↔ Trajectory、Chat 内部 transcript 较短 ↔ transcript 可滚动,以及 hero ↔ 第一个可滚动轮次。
|
||||
- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。
|
||||
- 提交的 golden 记录了预留的带宽,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。
|
||||
- 提交的 golden 记录了预留条带,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。
|
||||
|
||||
## 测试
|
||||
|
||||
`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。
|
||||
|
||||
该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两侧带宽都是 0;去掉它则是 8 与 0。
|
||||
该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两条预留带的宽度都是 0;去掉它则分别是 8 与 0。
|
||||
|
||||
随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`,overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把"卡片确实没动"与"标签页切换根本没到达布局"区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture 承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。
|
||||
随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`,overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把「卡片确实没动」与「标签页切换根本没到达布局」区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture(测试前置数据)承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md
|
||||
2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d
|
||||
2026-08-04-conversation-column-one-axis-scroll.zh.md: 23441a7c8655d1f19d3c0fe0f661f81f69b55dba
|
||||
2026-08-04-conversation-column-one-axis-scroll.zh.md: b86f86f55757dff4fddab4c4e2ac64fa7c19fe59
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Agent Note:会话列只在一个轴上滚动
|
||||
# Agent Note: 会话列只在一个轴上滚动
|
||||
|
||||
状态:已实现
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
这处外溢是设计使然,保持不变。真正让它对用户可见的是它所处的滚动容器。`[data-conversation-scroll]` 只声明了 `overflow-y: auto`,另一个轴留在初始值 `visible`;而一个在某一轴上滚动的盒子,会把另一轴的 `visible` 计算为 `auto`。于是每一条比该椭圆窄的列都真的给出了一段横向滚动范围——在笔记本实际会产生的几档宽度上,实测为 24–95px。
|
||||
|
||||
## 决定
|
||||
## 决策
|
||||
|
||||
`.scrollBody` 声明 `overflow-x: hidden`。这条列明确声明自己是单轴滚动容器,而不是把第二个轴交给推导。
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
**把椭圆缩到列内。** 否决。椭圆的宽度正是让它 `stdDeviation="50"` 的模糊随输入卡片缩放的依据(figma 313:14109);约束宽度会使列越窄模糊越紧,等于为修一条滚动条而制造一处视觉回归。
|
||||
|
||||
**给椭圆套一层裁剪盒。** 否决。这层盒子唯一的职责是抵消列本就会裁剪的溢出,而推导出的 `overflow-x: auto` 仍然留在原处,等着下一个外溢的元素——会话流里这样的候选者不少。
|
||||
**给椭圆套一层裁剪盒。** 否决。这层盒子唯一的职责是抵消列本就会裁剪的溢出,而推导出的 `overflow-x: auto` 仍然留在原处,等着下一个外溢的元素——transcript(文本记录)里这样的候选者不少。
|
||||
|
||||
**依赖外框的 `.centerCol { overflow: hidden }`。** 它帮不上忙。那处裁剪在滚动容器之外,只能在列边界处遮住椭圆探出的部分,而里面的容器照样可以滚过去够到它。用户报告的那条滚动条属于内层容器。
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
|
||||
## 测试
|
||||
|
||||
[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上向列横向滚轮并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。
|
||||
[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上于列上触发横向滚轮事件并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。
|
||||
|
||||
两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到正向滚动边界。测试直接测量该边界,因为稳定的滚动条槽可能让部分外溢处于滚动原点的负向。没有这项对照,`scrollLeft` 读到 0 同样可以解释为滚轮根本没送达。
|
||||
两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到正向滚动边界。测试直接测量该边界,因为稳定的滚动条槽可能让部分外溢处于滚动原点的负向。没有这项对照,`scrollLeft` 读到 0 同样可以解释为滚轮事件根本没送达。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md
|
||||
2026-08-04-load-pre-react-loop-sessions.md: b7dcad1ff7fe0aa8239ac03f52b50dfa55417515
|
||||
2026-08-04-load-pre-react-loop-sessions.zh.md: 14c6dcc7b4da016ebef424a66fae0b96fb469cd4
|
||||
2026-08-04-load-pre-react-loop-sessions.md: 277a481f366f1182fd3948caf858607efd550e5e
|
||||
2026-08-04-load-pre-react-loop-sessions.zh.md: 30e482cbb1c761f7296dbcb083dc0a76f3fa8f85
|
||||
|
||||
@@ -24,7 +24,7 @@ The importer does not synthesize inbox splices. A resumed pre-react-loop agent b
|
||||
|
||||
**Replay old inbox notifications into durable splices.** Those notifications were not session events and do not provide a trustworthy pending-state snapshot. Inferring insertions without every claim and discard would re-run consumed work.
|
||||
|
||||
**Assign coarse aborted records to an existing caller.** Mapping them to `user`, `parent`, or `hook` would manufacture provenance. A dedicated `legacy` cause keeps the stop classification without making a false audit claim.
|
||||
**Assign coarse aborted records to an existing caller.** Mapping them to `user`, `parent`, or `hook` would invent a caller that the old record did not name. A dedicated `legacy` cause keeps the stop classification without making a false audit claim.
|
||||
|
||||
**Rewrite stored JSONL and SQLite records.** A rewrite would violate the append-only contract and require backend-specific atomic migration machinery for a read compatibility boundary.
|
||||
|
||||
|
||||
@@ -14,23 +14,23 @@ react-loop 简化在保持 `SESSION_FORMAT_VERSION` 为 0 的同时更改了持
|
||||
|
||||
`PersistenceCoordinator` 会在后端解码后识别 react-loop 重构前的确切形状,并将其投影为当前读取视图。它移除已废弃的 `turn/start.trigger`,把 `steering/message` 转换为同一条带标识的 `user/message`,将旧版失败事实映射为当前结构化错误,把 `disposed` 折叠为带 `disposed` 原因的已中止轮次,并用仅供持久化导入使用的 `{ kind: 'legacy' }` 原因表示粗粒度中止记录,因为无法获得其调用方。
|
||||
|
||||
协调器会把该投影应用于 `load`、`inspect`、接管、HMR 前缀比较和 `readFrom`。可寻址的 `readFrom` 通常只读取后缀;如果后缀包含需要更早替换标识的旧版事件,协调器会先加载并规范化完整前缀,再返回所请求的 seq 范围。
|
||||
协调器会把该投影应用于 `load`、`inspect`、接管、HMR(热模块替换)前缀比较和 `readFrom`。可寻址的 `readFrom` 通常只读取后缀;如果后缀包含需要更早替换标识的旧版事件,协调器会先加载并规范化完整前缀,再返回所请求的 seq 范围。
|
||||
|
||||
导入器不会合成 inbox splice。恢复后的 react-loop 重构前 agent 从空的待处理列表开始,这与基线运行时无法持久化待处理 inbox 工作的行为一致。已存储产物仍然仅追加,后续事件使用当前格式。
|
||||
导入器不会合成 inbox splice。恢复后的 react-loop 重构前 agent(智能体)从空的待处理列表开始,这与基线运行时无法持久化待处理 inbox 工作的行为一致。已存储产物仍然仅追加,后续事件使用当前格式。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**将同版本记录视为不受支持。** 这符合预发布阶段的默认立场,但会使 PR 基线产生的会话无法恢复,尽管已移除的 steering 内容和终止事实都有完整映射。
|
||||
**将同版本记录视为不受支持。** 这符合预发布阶段的默认立场,但会使 PR(Pull Request)基线产生的会话无法恢复,尽管已移除的 steering 内容和终止事实都有完整映射。
|
||||
|
||||
**将旧 inbox 通知回放为持久 splice。** 这些通知不是会话事件,也无法提供可信的待处理状态快照。如果无法获知每一次领取和丢弃,就推断插入操作,会让已消费的工作再次执行。
|
||||
|
||||
**将粗粒度中止记录归因于现有调用方。** 将其映射到 `user`、`parent` 或 `hook` 会虚构来源。专用的 `legacy` 原因既能保留停止分类,也不会产生虚假的审计事实。
|
||||
**将粗粒度中止记录归因于现有调用方。** 将其映射到 `user`、`parent` 或 `hook` 会凭空指定旧记录未注明的调用方。专用的 `legacy` 原因既能保留停止分类,也不会产生虚假的审计事实。
|
||||
|
||||
**重写已存储的 JSONL 和 SQLite 记录。** 重写会违反仅追加契约,并要求为读取兼容边界建立后端专用的原子迁移机制。
|
||||
**重写已存储的 JSONL 和 SQLite 记录。** 重写会违反仅追加约定,并要求为读取兼容边界建立后端专用的原子迁移机制。
|
||||
|
||||
## 后果
|
||||
|
||||
以重构基线格式写入的会话可以通过当前 AgentLoop 恢复,并完整保留 steering 内容、轮次边界、错误事实和停止分类。共享协调器契约覆盖内存、JSONL 和 SQLite 的 `load`/`inspect`/`readFrom`,包括 SQLite 后缀回退;组装后的 JSONL agent 恢复用例会验证历史 transcript(文本记录)可见,同时两个新 inbox 列表都从空状态开始。
|
||||
以重构基线格式写入的会话可以通过当前 AgentLoop 恢复,并完整保留 steering 内容、轮次边界、错误事实和停止分类。共享协调器约定覆盖内存、JSONL 和 SQLite 的 `load`/`inspect`/`readFrom`,包括 SQLite 后缀回退;组装后的 JSONL agent 恢复用例会验证历史 transcript(文本记录)可见,同时两个新 inbox 列表都从空状态开始。
|
||||
|
||||
此例外支持基线格式,不支持重构开发期间产生的中间格式。具体而言,它没有为更早的实验性 `agent/inbox/spliced` 载荷定义迁移。通过确切形状识别,当前格式外观相似但结构错误的记录仍会走拒绝路径,不会被猜测性地转换为有效记录。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md
|
||||
2026-08-05-context-meter-blind-to-compaction.md: 8f4845c3c9bf52c5c3a2d39dee2ff25bda30c7bd
|
||||
2026-08-05-context-meter-blind-to-compaction.zh.md: 94ada0a9118db63876d5805ef5a8197d9c764102
|
||||
2026-08-05-context-meter-blind-to-compaction.md: 10ded250cec9c92d90803bdf5969cc7f5aa54c50
|
||||
2026-08-05-context-meter-blind-to-compaction.zh.md: ca3fe59eecde82bb97b44af4c3383546d5672753
|
||||
|
||||
@@ -27,7 +27,7 @@ This reverses the "the ring, header, and bar length stay provider-exact" half of
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk provenance (`session.events[seq]`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold.
|
||||
**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk events cited by seq (`session.events[seq]`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold.
|
||||
|
||||
**Emit a synthetic usage record at the end of compaction.** Would move `pressureTokens` itself, but the only usage compaction holds is the summarization request's own — a different prompt entirely. Recording it as the conversation's prompt size would be a lie in the durable log rather than in one display.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note:上下文仪表看不见压缩
|
||||
# Agent Note: 上下文仪表看不见压缩
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,28 +6,28 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
composer 的[上下文仪表](../feature/2026-08-05-composer-context-meter-breakdown.md)的圆环、百分比与 `~已用 / 容量` 标题都取自 `contextPressure.pressureTokens`,即提供方报告的最新提示词规模。这个数字只在某个请求报告用量时才会移动,而压缩不报告用量:`compact-basic` 通过直连的 `ctx.llm.stream()` 调用生成摘要,只追加 `compact/start`、`compact/summary`、用作替换的 `user/message` 和 `compact/end`——没有 `assistant/message`,也没有用量分片。
|
||||
composer 的[上下文仪表](../feature/2026-08-05-composer-context-meter-breakdown.md)的圆环、百分比与 `~已用 / 容量` 标题都取自 `contextPressure.pressureTokens`,即提供方报告的最新提示词规模。这个数字只在某个请求报告用量时才会移动,而压缩(compaction)不报告用量:`compact-basic` 通过直连的 `ctx.llm.stream()` 调用生成摘要,只追加 `compact/start`、`compact/summary`、用作替换的 `user/message` 和 `compact/end`——没有 `assistant/message`,也没有用量分片。
|
||||
|
||||
于是在唯一一个专门用来改变它的操作面前,这块仪表纹丝不动。通过真实 agent loop 驱动一次 `compactNow`:
|
||||
于是在唯一一个专门用来改变它的操作面前,这块仪表纹丝不动。通过真实的 agent loop(智能体循环)驱动一次 `compactNow`:
|
||||
|
||||
```
|
||||
BEFORE compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messages 4365]
|
||||
AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messages 286]
|
||||
```
|
||||
|
||||
折叠表层得出的组成明细行下降了 93%。而圆环——那个主要的可操作元素,也正是用户压缩完立刻会去点开面板的理由——完全没动,而且要等到又跑完一整轮才会动。此时面板上的标题与明细行相差一个数量级以上,恰恰发生在读者最可能去把明细行加总的时刻。
|
||||
折叠表层得出的组成明细行下降了 93%。而圆环——那个主要的可操作元素,也正是用户压缩完立刻会去点开面板的理由——完全没动,而且要等到又跑完一整个轮次才会动。此时面板上的标题与明细行相差一个数量级以上,恰恰发生在读者最可能去把明细行加总的时刻。
|
||||
|
||||
## 决策
|
||||
|
||||
`contextPressure` 发布第二个分子 `projectedTokens`:在提供方样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零。该折叠通过共享的 `surface-fold.ts` 携带已计价的表层,并在用量样本落地时记下 `sampledSurfaceTokens`——记录时机在同一条事件加入表层**之前**,因此 `assistant/message` 锚定的正是它自己那次请求实际携带的表层。`stateVersion` 提升到 3。
|
||||
|
||||
只有增量部分是估算的。锚点保持提供方精确值,从而把估算器对 CJK 文本与 JSON schema 的系统性低估挡在占用率数字之外,同时又让这个数字能在内容落地或某段区间被遮蔽的瞬间做出反应。`contextOccupancy` 读取 `projectedTokens`,并回退到裸样本,因此从不含该字段的检查点恢复出来的投影会退化为旧行为,而不是直接消失。
|
||||
只有增量部分是估算的。锚点保持提供方精确值,从而把估算器对 CJK 文本与 JSON Schema 的系统性低估挡在占用率数字之外,同时又让这个数字能在内容落地或某段区间被遮蔽的瞬间做出反应。`contextOccupancy` 读取 `projectedTokens`,并回退到裸样本,因此从不含该字段的检查点恢复出来的投影会退化为旧行为,而不是直接消失。
|
||||
|
||||
这推翻了[上下文仪表决策](../feature/2026-08-05-composer-context-meter-breakdown.md)中「圆环、标题与进度条总长保持提供方精确值」的那一半。那条决策真正想守住的东西——不要把启发式明细行按比例缩放到提供方总量、从而伪造精度——依然守住了:明细行仍未被缩放,标题仍不等于它们之和。改变的是这样一个认识:「提供方精确、但描述的是两次压缩之前那个请求」并不是更真实的数字。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对分片来源的随机访问(`session.events[seq]`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。
|
||||
**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对按 seq 引用的分片事件进行随机访问(`session.events[seq]`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。
|
||||
|
||||
**在压缩结束时补写一条合成的用量记录。** 这确实能推动 `pressureTokens` 本身,但压缩手上唯一的用量是摘要请求自己的用量——那是完全另一个提示词。把它记成本对话的提示词规模,等于把谎言写进持久日志,而不只是写进某一处展示。
|
||||
|
||||
@@ -35,12 +35,12 @@ AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messag
|
||||
|
||||
## 影响
|
||||
|
||||
占用率现在随每个表层事件推进,而不再是每轮跳一次,因此一轮中产生工具结果时圆环会持续爬升,而不是等到轮次结束才跳变——压缩落地的瞬间它也会掉下来。代价是线路上多了投影帧:`contextPressure` 每个表层事件推一帧,也就是 `contextBreakdown` 本来就在跑的频率。
|
||||
占用率现在随每个表层事件推进,而不再是每个轮次跳一次,因此一个轮次中产生工具结果时圆环会持续爬升,而不是等到轮次结束才跳变——压缩落地的瞬间它也会掉下来。代价是线路上多了投影帧:`contextPressure` 每个表层事件推一帧,也就是 `contextBreakdown` 本来就在跑的频率。
|
||||
|
||||
面板的组成明细行仍然加不出标题数字,但现在只剩一个能讲清楚的原因,而不是两个:明细行带着估算器的误差,标题的锚点不带。剩下的抓手是估算精度(在 `estimate.ts` 里做 CJK 感知加权),它不改动任何 seam。
|
||||
|
||||
`sampledSurfaceTokens` 依赖一个前提:在某个步骤的请求与它的用量报告之间,不会有新内容加入表层。循环在 `buildRequest` 之前接纳 steering 与 context,在 `assistant/message` 之后才排空工具结果,因此该前提成立;即便将来不再成立,误差也被限制在一条消息以内,并在下一个样本处自行纠正。
|
||||
`sampledSurfaceTokens` 依赖一个前提:在某个步骤的请求与它的用量报告之间,不会有新内容加入表层。agent loop 在 `buildRequest` 之前接纳 steering(中途引导)与上下文,在 `assistant/message` 之后才排空工具结果,因此该前提成立;即便将来不再成立,误差也被限制在一条消息以内,并在下一个样本处自行纠正。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了样本在表层增长与一次压缩上的推进(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。
|
||||
`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了投影值在表层增长与一次压缩期间的延续更新(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md
|
||||
2026-08-05-turn-tail-actions-require-a-completed-turn.md: 44a890c955089096204a5b2a2833905c9ef9f7ed
|
||||
2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md: 58ca9b2519101cae12121cd74e13bdaa90ce23cb
|
||||
2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md: afd7fbe5a0cdd097c919fb3457dfed1771d5651b
|
||||
|
||||
@@ -14,7 +14,7 @@ assistant IconActions 此前只从已定稿的 transcript(文本记录)推
|
||||
|
||||
`assistantActionsSeqs` 接收 `ConversationSnapshot.turnEnds`,只在事件窗口中存在该轮次 `turn/end` 时才授予该行。已完成轮次内部的归属不变,仍是其最后一条含内容文本的 assistant。仍在产出步骤的轮次不授予任何座位,因此其叙述不会挂载该行;轮次关闭时,座位在已定稿答案下方一次性出现。
|
||||
|
||||
这与分支控件和运行时长标签使用的完成事实相同,因此同一行的三个部分现在口径一致。轮次是否完成读自持久的 `turn/end` 事件,而不是从 `running`、流式 partial 或在途工具调用推断,与[已完成轮次尾部决策](2026-08-02-message-fork-actions-require-completed-turn-tail.md)一致。任何 reason 类别都会关闭轮次,因此已中止轮次冻结的尾部保留其操作栏,而崩溃遗留的开放轮次会在加载时由日志修复补上 `turn/end`。
|
||||
这与分支控件和运行时长标签使用的完成事实相同,因此同一行的三个部分现在口径一致。轮次是否完成读自持久的 `turn/end` 事件,而不是从 `running`、流式 partial 或在途工具调用推断,与[已完成轮次尾部决策](2026-08-02-message-fork-actions-require-completed-turn-tail.md)一致。任何结束原因类别都会关闭轮次,因此已中止轮次冻结的尾部保留其操作栏,而崩溃遗留的开放轮次会在加载时由日志修复补上 `turn/end`。
|
||||
|
||||
`hasContentText` 移入 `chat-flow.ts`,由 `AssistantMarkdown` 导入,使归属门控与挂载门控无法各自漂移。
|
||||
|
||||
@@ -24,8 +24,8 @@ assistant IconActions 此前只从已定稿的 transcript(文本记录)推
|
||||
|
||||
**轮次进行中保留该行,只把控件置为不可用。** 不予采纳:轮次中间的叙述不是一个降级的答案,它根本不是答案。复制仍然会写入一句中间文本,该行在轮次结束时仍然要移动到真正的尾部。
|
||||
|
||||
**让每个已定稿的内容节点长期保留该行。** 在此重新否决,理由与最初的决策相同:在每一步下重复复制、分支和时钟会打乱流程。它也解决不了本次报告的问题,因为分支控件只有落在尾部才有意义。
|
||||
**让每个已定稿的内容节点长期保留该行。** 在此重新否决,理由与最初的决策相同:在每一步下重复复制、分支和时钟会使流程显得杂乱。它也解决不了本次报告的问题,因为分支控件只有落在尾部才有意义。
|
||||
|
||||
## 后果
|
||||
|
||||
运行中的轮次在触发它的用户气泡之下不再有任何消息操作栏,而此前每个已完成轮次仍保留各自的座位;座位在 `turn/end` 到达时一次性出现,此刻已定稿答案下方会多出一行 28px。`turn/end` 落在加载窗口之外的轮次不授予座位,而翻页不会造成这种情况,因为一个轮次的结束事件排在它自己的节点之后。`apps/web/tests/turn-tail-actions.e2e.ts` 通过组装后的应用钉住两种状态:`hang` sidecar 作用在第二次模型调用上,把一个首步先叙述再调用 bash 的轮次挂住,两份 golden 分别记录挂起中的流程和停止之后的流程。包级测试直接覆盖该推导以及运行中轮次的渲染结果。
|
||||
运行中的轮次在触发它的用户气泡之下不再有任何消息操作栏,而更早的每个已完成轮次仍保留各自的座位;座位在 `turn/end` 到达时一次性出现,此刻已定稿答案下方会多出一行 28px。`turn/end` 落在加载窗口之外的轮次不授予座位,而翻页不会造成这种情况,因为一个轮次的结束事件排在它自己的节点之后。`apps/web/tests/turn-tail-actions.e2e.ts` 通过组装后的应用钉住两种状态:`hang` sidecar 作用在第二次模型调用上,把一个首步先叙述再调用 bash 的轮次挂住,两份预期输出分别记录挂起中的流程和停止之后的流程。包级测试直接覆盖该推导以及运行中轮次的渲染结果。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md
|
||||
2026-08-05-workspace-blank-session-reuse-membership.md: 910a10e9ada1a835df7a38a04fb04c504b0921df
|
||||
2026-08-05-workspace-blank-session-reuse-membership.zh.md: 7e7aa899f73955b3d34a1eff3d9fedde097a17f0
|
||||
2026-08-05-workspace-blank-session-reuse-membership.zh.md: 0581b768679d78716c1bf70cfffec825c7281783
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Agent Note:工作区新建会话复用了 cwd 匹配但未入账的空白会话
|
||||
# Agent Note: 工作区新建会话复用了 cwd 匹配但未入账的空白会话
|
||||
|
||||
状态:已实现
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-05-workspace-blank-session-reuse-membership.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
在侧边栏某个工作区分组的 `+` 上创建会话时,有时会进入一个新会话,但侧边栏把它显示在「未分组」而不是点击的那个工作区下——「进入了新会话,但工作区没有被选中」。故障只出现在注册在 CLI 运行目录(即 `defaults.cwd = process.cwd()`,实际场景里就是 harness 检出目录本身)上的工作区,并且一旦该目录下存在 CLI 创建的空白会话就会出现。
|
||||
在侧边栏某个工作区分组的 `+` 上创建会话时,有时会进入一个新会话,但侧边栏把它显示在「未分组」而不是点击的那个工作区下——「进入了新会话,但工作区没有被选中」。故障只出现在注册在 CLI(命令行界面)运行目录(即 `defaults.cwd = process.cwd()`,实际场景里就是 harness 检出目录本身)上的工作区,并且一旦该目录下存在 CLI 创建的空白会话就会出现。
|
||||
|
||||
根因:`connectWorkspace` 的空白会话复用扫描只按 `cwd` 相等匹配会话列表镜像。host 自己的成员规则要求**同时**满足:会话 id 在工作区账户(`sessionIds`)中,**且**会话 header 的规范化 cwd 等于工作区路径([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md));只有 cwd 匹配而没有账户槽位的恰恰就是「未分组」的情形。复用扫描忽略了账户槽位,因此任何 cwd 匹配的**在线空白**会话都会被选中——包括 CLI/TUI/headless 入口在 host cwd 创建的 `main-session-*` 会话(`session.create({})` 回退到 `defaults.cwd`,从不挂到任何工作区)。当这样的会话在线且空白(尚无 `turn/start`)时,下一次在该路径注册的工作区上点击 `+` 就会复用它,导航打开的是一个任何分组表面都无法显示在该工作区下的会话。其他路径的工作区不受影响,因为那里不会积累未入账的空白会话;而 host-cwd 工作区每次 CLI 运行都会积累一个。
|
||||
根因:`connectWorkspace` 的空白会话复用扫描只按 `cwd` 相等匹配会话列表镜像。host 自己的成员规则要求**同时**满足:会话 id 在工作区账户(`sessionIds`)中,**且**会话 header 的规范化 cwd 等于工作区路径([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md));只有 cwd 匹配而没有账户槽位的恰恰就是「未分组」的情形。复用扫描忽略了账户槽位,因此任何 cwd 匹配的**在线空白**会话都会被选中——包括 CLI/TUI/headless 入口在 host cwd 创建的 `main-session-*` 会话(`session.create({})` 回退到 `defaults.cwd`,从不挂到任何工作区)。当这样的会话在线且空白(尚无 `turn/start`)时,下一次在该路径注册的工作区上点击 `+` 就会复用它,导航打开的是一个任何分组界面都无法显示在该工作区下的会话。其他路径的工作区不受影响,因为那里不会积累未入账的空白会话;而 host-cwd 工作区每次 CLI 运行都会积累一个。
|
||||
|
||||
## 决定
|
||||
## 决策
|
||||
|
||||
复用扫描现在要求工作区成员关系:`blank` 且 `summary.cwd === workspace.path` 且 `workspace.sessionIds.includes(summary.id)` 且未归档。仅 cwd 匹配的情况落到 `session.create({ workspaceId })`,创建并挂接新会话,使工作区拥有它——这与流程中「不存在空白会话」时的既有分支完全相同。
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
|
||||
**收养游离会话而不是新建。** 让 `session.create({ workspaceId })` 挂接一个 cwd 匹配但未入账的空白会话。否决:静默地把 CLI 创建的会话挂到工作区上,越过了账户边界,令人意外;而且客户端没有成员视图就无法区分「游离会话」与「工作区自己的空白会话」——而成员视图本身就是本次修复。
|
||||
|
||||
**复用时就地挂接,新增一条 wire 操作。** 需要在导航热路径上新增 `workspace.attachSession` RPC,并且会话仍会有一帧显示在「未分组」;没有产品需求值得新增这个表面。
|
||||
**复用时就地挂接,新增一条 wire 操作。** 需要在导航热路径上新增 `workspace.attachSession` RPC,并且会话仍会有一帧显示在「未分组」;没有产品需求值得新增这个接口。
|
||||
|
||||
## 后果
|
||||
|
||||
游离空白会话仍显示在「未分组」(用户仍可手动打开),但不再被某个工作区的新建会话流程劫持。成员校验是复用扫描的新增条件,有一个可观察的镜像滞后边界:在会话镜像已新而工作区账户帧滞后的窗口里,工作区自己的成员空白会话可能因成员校验失败而错过复用,多创建一个空白——表现为该工作区下出现第二个「新会话」行,与旧故障形态(打开一个任何分组表面都无法显示的会话)不同。两个窗口都是瞬态的,按工作区的合并逻辑仍然防止并发创建互相竞争。无 host、wire 或持久化格式变更。
|
||||
游离空白会话仍显示在「未分组」(用户仍可手动打开),但不再被某个工作区的新建会话流程劫持。成员校验是复用扫描的新增条件,有一个可观察的镜像滞后边界:在会话镜像已是最新而工作区账户帧滞后的窗口里,工作区自己的成员空白会话可能因成员校验失败而错过复用,多创建一个空白——表现为该工作区下出现第二个「新会话」行,与旧故障形态(打开一个任何分组界面都无法显示的会话)不同。两个窗口都是瞬态的,按工作区的合并逻辑仍然防止并发创建互相竞争。无 host、wire 或持久化格式变更。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次 prompt 被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。
|
||||
`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md
|
||||
2026-08-06-api-key-format-validation.md: 2174cb466c6af72f15005ce1ba3dec8100de6f2f
|
||||
2026-08-06-api-key-format-validation.zh.md: f9b7d6518fedc42e5264c46beaa1a78619139c58
|
||||
2026-08-06-api-key-format-validation.md: 356891c01c003f26d8edd092ebde8887e17d2448
|
||||
2026-08-06-api-key-format-validation.zh.md: 156e3141549c94f609d68f1937f30202c40751c6
|
||||
|
||||
@@ -42,9 +42,9 @@ The rule applies to a value that was *provided*; deciding whether one was provid
|
||||
|
||||
### Where the rule lives
|
||||
|
||||
`normalizeApiKey` is a module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`).
|
||||
`normalizeApiKey` is a module of the `dsh-llm` Service Definition, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`).
|
||||
|
||||
Both adapters also need the identical "refuse a stored credential" diagnosis, differing only by package prefix. `LlmError` is declared in the seam's `index.ts`, so `assertUsableApiKey(raw, pkg, ref)` lives there beside it and neither adapter carries a local copy. The predicate module stays dependency-free: importing `LlmError` into `api-key.ts` would cycle with `index.ts`'s re-export of it.
|
||||
Both adapters also need the identical "refuse a stored credential" diagnosis, differing only by package prefix. `LlmError` is declared in the Service Definition's `index.ts`, so `assertUsableApiKey(raw, pkg, ref)` lives there beside it and neither adapter carries a local copy. The predicate module stays dependency-free: importing `LlmError` into `api-key.ts` would cycle with `index.ts`'s re-export of it.
|
||||
|
||||
The client cannot import any of this: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate in its own `apiKey.ts` and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema. Each side names the other in a comment.
|
||||
|
||||
@@ -66,7 +66,7 @@ The client cannot import any of this: client packages reference only client pack
|
||||
|
||||
**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here.
|
||||
|
||||
**A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for.
|
||||
**A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the Service Definition, so that package can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for.
|
||||
|
||||
**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose request header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe.
|
||||
|
||||
@@ -80,7 +80,7 @@ The client cannot import any of this: client packages reference only client pack
|
||||
|
||||
A malformed key is refused at the field that holds it, and a malformed stored key fails as `INVALID_CREDENTIAL` with a message naming where to fix it and no fragment of the key. Because that code sits outside `DEFAULT_RETRYABLE_CODES`, a deterministic credential fault is no longer retried three times as a transport blip. `llm-pi-ai` discovery reports an illegal probe key as a credential fault instead of an unreachable endpoint.
|
||||
|
||||
The shape heuristic can refuse a real key. The first draft matched any upper-case identifier followed by `=`, which review showed was broader than intended: an all-upper-case base64 key ending in padding (`ABCD==`) matched an assignment it does not resemble. Requiring a non-`=` character after the separator excludes padding, since base64 only ever pads at the end. What remains — an upper-case name, one `=`, then a value — is a shape no known provider issues, and the rule runs only in the browser, so a user who still hits it can set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported.
|
||||
The shape heuristic can refuse a real key. Matching any upper-case identifier followed by `=` would be broader than intended: an all-upper-case base64 key ending in padding (`ABCD==`) would match an assignment it does not resemble. Requiring a non-`=` character after the separator excludes padding, since base64 only ever pads at the end. What remains — an upper-case name, one `=`, then a value — is a shape no known provider issues, and the rule runs only in the browser, so a user who still hits it can set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported.
|
||||
|
||||
Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened.
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ Status: implemented
|
||||
|
||||
[English](2026-08-06-api-key-format-validation.md) | 中文
|
||||
|
||||
## Problem
|
||||
## 问题
|
||||
|
||||
一个含有 HTTP header value 无法承载的字符的 API Key,曾被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。
|
||||
一个含有 HTTP header value 无法承载的字符的 API Key,曾被每个配置入口接受,直到构造请求时才失败——离引发它的那个字段已经很远。
|
||||
|
||||
把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。两个适配器都不检查来自凭据或环境的 Key——而那正是 Models 页写入的路径,也就是用户真正走的路径。
|
||||
|
||||
## Decision
|
||||
## 决策
|
||||
|
||||
一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。
|
||||
|
||||
@@ -24,7 +24,7 @@ Status: implemented
|
||||
|
||||
### 不变量属于每一层,启发式属于人所在的那一层
|
||||
|
||||
字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。
|
||||
字符集规则是不变量。非 ASCII 字符对任何提供方都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。
|
||||
|
||||
形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。
|
||||
|
||||
@@ -32,7 +32,7 @@ Status: implemented
|
||||
|
||||
规则作用于*已提供*的值;至于究竟有没有提供,由各个调用方自行判断。
|
||||
|
||||
**未点名凭据。** 省略 `apiKeyEnv` 的 pi-ai profile 可以在 harness 持有的凭据路径之外鉴权。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现继续工作;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权。`namesCredential` 承载这一区分;省略不是需要校验的值。
|
||||
**未点名凭据。** 省略 `apiKeyEnv` 的 pi-ai profile 可以在 harness 持有的凭据路径之外鉴权。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog 提供方自身的鉴权,正是为了让 provider 原生的 ambient 发现继续工作;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权。`namesCredential` 承载这一区分;省略不是需要校验的值。
|
||||
|
||||
**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。
|
||||
|
||||
@@ -42,15 +42,15 @@ Status: implemented
|
||||
|
||||
### 规则住在哪里
|
||||
|
||||
`normalizeApiKey` 是 `dsh-llm` seam 的一个模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。
|
||||
`normalizeApiKey` 是 `dsh-llm` Service Definition 的一个模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费方而非一个预设消费方。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。
|
||||
|
||||
两个适配器同样都需要那句完全相同的「拒绝一个已存储凭据」的诊断,差别仅在包名前缀。`LlmError` 声明在 seam 的 `index.ts` 中,因此 `assertUsableApiKey(raw, pkg, ref)` 就住在它旁边,两个适配器都不再各留一份。断言模块本身保持零依赖:把 `LlmError` 引入 `api-key.ts` 会与 `index.ts` 对它的再导出成环。
|
||||
两个适配器同样都需要那句完全相同的「拒绝一个已存储凭据」的诊断,差别仅在包名前缀。`LlmError` 声明在 Service Definition 的 `index.ts` 中,因此 `assertUsableApiKey(raw, pkg, ref)` 就住在它旁边,两个适配器都不再各留一份。断言模块本身保持零依赖:把 `LlmError` 引入 `api-key.ts` 会与 `index.ts` 对它的再导出成环。
|
||||
|
||||
客户端无法引入其中任何一个:client 包只 reference client 包,因此 `packages/client/ui-models` 在自己的 `apiKey.ts` 中镜像这个断言并持有本地化文案,正如 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。
|
||||
|
||||
### 各个界面各做什么
|
||||
### 各处分别做什么
|
||||
|
||||
| 界面 | 行为 |
|
||||
| 位置 | 行为 |
|
||||
|---|---|
|
||||
| `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 |
|
||||
| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 |
|
||||
@@ -58,15 +58,15 @@ Status: implemented
|
||||
| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 |
|
||||
| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 |
|
||||
|
||||
`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。`CustomProviderCard` 为手工声明的路由承载同一套判定。
|
||||
`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个提供方。`CustomProviderCard` 为手工声明的路由承载同一套判定。
|
||||
|
||||
`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。
|
||||
|
||||
## Alternatives considered
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。
|
||||
|
||||
**在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。
|
||||
**在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 Service Definition 中,因此该包完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。
|
||||
|
||||
**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的请求 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。
|
||||
|
||||
@@ -74,15 +74,15 @@ Status: implemented
|
||||
|
||||
**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。
|
||||
|
||||
**在保存时探测 provider 以证明 Key 可用。** 它能关掉最初报告的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。
|
||||
**在保存时探测 provider 以证明 Key 可用。** 它能关掉最初报告的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些提供方,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。
|
||||
|
||||
## Consequences
|
||||
## 后果
|
||||
|
||||
格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 `DEFAULT_RETRYABLE_CODES` 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。`llm-pi-ai` 的探测把非法 Key 报为凭据故障,而非端点不可达。
|
||||
|
||||
形状启发式可能拒绝一个真实的 Key。最初的写法匹配任意「全大写标识符接 `=`」,评审指出其覆盖面比预期更宽:一个以 padding 结尾的全大写 base64 Key(`ABCD==`)会命中它并不像的赋值形态。要求分隔符之后必须是非 `=` 字符即可排除 padding——base64 的 padding 只出现在末尾。剩下的形态(大写名称、一个 `=`、然后是值)是已知 provider 不会签发的,且该规则只在浏览器中运行,因此仍撞上它的用户可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。
|
||||
形状启发式可能拒绝一个真实的 Key。匹配任意「全大写标识符接 `=`」会比预期覆盖面更宽:一个以 padding 结尾的全大写 base64 Key(`ABCD==`)会命中它并不像的赋值形态。要求分隔符之后必须是非 `=` 字符即可排除 padding——base64 的 padding 只出现在末尾。剩下的形态(大写名称、一个 `=`、然后是值)是已知提供方不会签发的,且该规则只在浏览器中运行,因此仍撞上它的用户可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。
|
||||
|
||||
限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。
|
||||
限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个提供方签发 latin-1 的 Key,这条规则需要放宽。
|
||||
|
||||
字符集断言存在两份,每个 source plane 一份。布局禁止共享它;两侧各自带测试并在注释中指名其孪生体。
|
||||
|
||||
@@ -90,7 +90,7 @@ Status: implemented
|
||||
|
||||
把这件事做错的最大代价,会是把「未指定」当成「非法」:一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由,而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都由测试钉住,而不是仅仰赖谨慎。
|
||||
|
||||
## Testing
|
||||
## 测试
|
||||
|
||||
`packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md
|
||||
2026-08-06-onboarding-step-owned-takeover-chrome.md: 4b3bbbc03c4494359297ae6e54bcc9a74c387e80
|
||||
2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: 548285d285939325a26df3b8d70c43fe952813a9
|
||||
2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: ba4f503f7bc318fcb16f4413465a86609fa1bc61
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
|
||||
接管界面框架属于步骤,不属于外壳。新增零 cordis 原语 `OnboardingSurface`(ui-primitives):渲染 portal 到 body 的浮层/遮罩/展示层——CSS 类名与几何从 `SettingsRoot.module.css` 逐字迁移——并在自身挂载生命周期内保持 `#root` 为 `inert`。两个步骤组件只把各自的**可见**分支包进该原语;既有的 `null` 分支由此在构造上不绘制、不阻塞任何内容,因为界面框架已是同一次渲染决策的一部分。
|
||||
|
||||
`SettingsRoot` 的协调器原样保留(有序账本投影、每次挂载一个步骤、本地完成集合、`stepId`/`complete`/`openSection` currency),但对当选步骤裸渲染——不再有 portal、展示层和 inert 效果。`settings.onboarding` 的 slot 契约现在写明:注册方持有外层包裹,且在私有事实未决时必须渲染 `null`。
|
||||
`SettingsRoot` 的协调器原样保留(有序账本投影、每次挂载一个步骤、本地完成集合、`stepId`/`complete`/`openSection` currency),但对当选步骤裸渲染——不再有 portal、展示层和 inert 效果。`settings.onboarding` 的 slot 约定现在写明:注册方持有外层包裹,且在私有事实未决时必须渲染 `null`。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**条件注册(账本即有内容信号)。** 私有 join 解析出「需要介入」后才注册条目。架构上干净(在 commit point 发布),但改动更大:join 的加载必须从对话框上移到各插件的 apply,注册/销毁在两个包里都变成响应式接线。对本缺陷而言过重,否决。
|
||||
|
||||
**把 `settings.onboarding` 改成 chain 并把完成集合外置为 store。** composer takeover 的版型;做过原型后回退。selector 只能判定 owner props,私有就绪事实仍然只能在组件内部解析——chain 买来的是当前两个步骤并不需要的路由通用性,代价却是跨三个包的契约变更。
|
||||
**把 `settings.onboarding` 改成 chain 并把完成集合外置为 store。** composer takeover 的版型;做过原型后回退。selector 只能判定 owner props,私有就绪事实仍然只能在组件内部解析——chain 买来的是当前两个步骤并不需要的路由通用性,代价却是跨三个包的约定变更。
|
||||
|
||||
**在渲染点探测 slot 输出为空。** `renderSlot` 无条件返回 outlet 元素,owner 无法据步骤的 `null` 分支;探测已渲染 DOM 是否为空需要先提交再撤回的手法,其动态翻转会失去 paint 前的保证。
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
步骤已挂载但尚未判定期间,应用保持可见且可交互:判定窗口内 `#root` 不再是 `inert`(此前是在不透明图层背后被置灰)。对真正未配置的用户,接管层比从前晚一个 join 往返出现——但一出现就带着内容,而不是先露出空白展示层再填充。
|
||||
|
||||
未来若有步骤注册后不把可见内容包进 `OnboardingSurface`,会无遮罩地裸渲染在应用之上;slot 契约的 JSDoc 已把包裹写为注册方的义务。
|
||||
未来若有步骤注册后不把可见内容包进 `OnboardingSurface`,会无遮罩地裸渲染在应用之上;slot 约定的 JSDoc 已把包裹写为注册方的义务。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings/tests/settings-root.spec.tsx` 钉住反转后的外壳契约:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。
|
||||
`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings/tests/settings-root.spec.tsx` 钉住反转后的外壳约定:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md
|
||||
2026-08-06-provider-credential-lifecycle.md: c28788921e8f1b233b44e19b29ad4d4acaa25022
|
||||
2026-08-06-provider-credential-lifecycle.zh.md: 2ea3b21fb6ceb4fa38a0cad0daf47c3b6a98a664
|
||||
2026-08-06-provider-credential-lifecycle.zh.md: 2791baa7761b243cbdd34251d1dc11678b8a9a65
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user