Merge remote-tracking branch 'origin/master' into codex/fix-compact-agents-reinjection

# Conflicts:
#	docs/architecture.i18n.yaml
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
This commit is contained in:
fz
2026-08-06 20:13:04 +08:00
635 changed files with 18029 additions and 3509 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md
2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc 2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3
2026-07-16-explicit-turn-cancellation.zh.md: 6f8b83fdb42af03c97dc2e8a9345a01acc6019fc 2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943
@@ -20,7 +20,7 @@ AgentLoop privately owns one `TurnCancellation` per prospective turn. It install
The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
The explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. `ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam.
@@ -44,7 +44,7 @@ Initiator-scope tests assert that every hook still observes the exact Agent and
**Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning. **Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning.
**Expose public turn or step context wrappers.** Existing positional seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority. **Expose public turn or step context wrappers.** Existing seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority.
**Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control seam. **Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control seam.
@@ -20,7 +20,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它
对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()``AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent``signal``next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最 `next` 之前。`PreStepContext``RequestFailureContext` 已退役,其字段并入 `agent/pre-step``agent/request-error` 的 payload[payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()``AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。
@@ -44,7 +44,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时
**现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。 **现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。
**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 **公开轮次或步骤上下文包装类型。** 现有 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。
**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 **在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
2026-07-19-gui-web-client-architecture.md: b306f3b155d9d9208066c3f25ad2c4fb4683b1ee 2026-07-19-gui-web-client-architecture.md: 1a91d88818c374a1637b546fb3ddf6647af68570
2026-07-19-gui-web-client-architecture.zh.md: 28632667c45b360eb2bc5f0d06f10b9df910770d 2026-07-19-gui-web-client-architecture.zh.md: 5c0bacde9836d45812895f5d9c89a0e8974ed7a1
@@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`; the declaration is the load and reload dependency, independently from `ConversationService` ([decision](2026-08-05-slot-declaration-injection.md)). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early.
**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
@@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader``ctx.theme``ctx.i18n``ctx.layout`(跨插件视图导航)、`ctx.conversation`send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader``ctx.theme``ctx.i18n``ctx.layout`(跨插件视图导航)、`ctx.conversation`send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entrytab 元数据随注册 options`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝(conversation 服务在场即保证槽已声明)。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entrytab 元数据随注册 options`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`;声明本身就是加载与重载依赖,不依赖 `ConversationService`[决策](2026-08-05-slot-declaration-injection.md))。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。
**scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6 2026-07-22-slot-type-chain-implementation.md: 2f0ec32766100e492c68c474f8798be3df0a3d15
2026-07-22-slot-type-chain-implementation.zh.md: 8ca6781e42764fc8d7f7de0f9f25ca6c110d4be0 2026-07-22-slot-type-chain-implementation.zh.md: 75e89d3f57b96a1699981123e8361c775db2b8a7
@@ -36,6 +36,8 @@ There is no separate slot-definition API. The `children` object both **declares
Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes; a chain registration missing its `select`. Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes; a chain registration missing its `select`.
A contributor whose activation order is independent from the declaring entry uses `ctx.slots.inject(key, callback)` and keeps direct `register()` fail-loud. The declaration, contributor, replacement, and failure lifetimes are specified by the [slot declaration injection decision](2026-08-05-slot-declaration-injection.md).
`SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type"). `SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type").
### Component props: four shares, each from its own source of truth ### Component props: four shares, each from its own source of truth
@@ -36,6 +36,8 @@ ctx.slots.register({
对等原则:**声明子 slot 的 entry 独占渲染这些子 slot 的权力**,全部在 register 时确定(配置错误会在装载时明确失败;渲染热路径不再校验)。装载即炸的情形:第二个 entry 声明已被声明的 slot;向未声明的 slot register;同一个 store 句柄挂到两个 scope 之下;chain 注册缺 `select`。 对等原则:**声明子 slot 的 entry 独占渲染这些子 slot 的权力**,全部在 register 时确定(配置错误会在装载时明确失败;渲染热路径不再校验)。装载即炸的情形:第二个 entry 声明已被声明的 slot;向未声明的 slot register;同一个 store 句柄挂到两个 scope 之下;chain 注册缺 `select`。
激活顺序独立于声明条目的贡献方使用 `ctx.slots.inject(key, callback)`,并让直接调用 `register()` 继续大声失败。声明、贡献方、替换与失败各自的生命周期由 [slot 声明注入决策](2026-08-05-slot-declaration-injection.md) 规定。
`SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。 `SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。
### 组件 props:四份额,各有唯一真源 ### 组件 props:四份额,各有唯一真源
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md
2026-07-23-toolview-dissolution.md: 406e5c181aabb635f9d6dcb12d8a9b8b6697368e 2026-07-23-toolview-dissolution.md: 97d8beb4de43d9bc6348d942e5460d0321592b32
2026-07-23-toolview-dissolution.zh.md: f42881c5f2e4c661d7fa40bfca7d0b53c1beef5e 2026-07-23-toolview-dissolution.zh.md: db93c6252d5d42d1fd85ce81ad430d95f4324cf2
@@ -14,7 +14,7 @@ After the view ring dissolved into the slot system, the client kept exactly one
The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively.
Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin using `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`; the declaration itself governs activation and replacement, without a false `ConversationService` edge ([decision](2026-08-05-slot-declaration-injection.md)). The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations.
Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option.
@@ -34,4 +34,4 @@ Four behavioral deltas were accepted deliberately, not overlooked. Cross-view ap
## Consequences ## Consequences
The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override), plus one subtlety the load-order seam carries: registrant plugins must declare `inject: ['conversation']` to sequence after the slot declaration, a convention the seam makes correct by construction but does not statically force on third parties. The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override). Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention.
@@ -14,7 +14,7 @@ Status: implemented
工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 keyask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 keyask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。
落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<domain>.<entry>.<hole>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方是使用 `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))` 的普通插件;声明本身控制激活与替换,不再引入虚假的 `ConversationService` 依赖([决策](2026-08-05-slot-declaration-injection.md)。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。
registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。 registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。
@@ -34,4 +34,4 @@ registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘
## Consequences ## Consequences
client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖),外加加载序缝携带的一处微妙:注册方插件须声明 `inject: ['conversation']` 才排在槽声明之后,这条约定由序缝构造保证正确、但不对第三方静态强制 client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖)。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: aeefbe22a397e3d7ffb9f6427a3c70c8c8e8b940 2026-07-25-web-client-session-scope-and-provide-channel.md: 3c51f06fca23a495f0fbc0cc4f1c289edea07b3b
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 056d50d45cef891e0e635d8bb4f2e73064ccdb87 2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 06fc1005785d9d11b52839f91c3bb4b99cad7d63
@@ -93,7 +93,7 @@ Slot scope is the closed set `root | session-maybe | session`:
- `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`. - `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`.
- `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store.
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. `conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
- The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing. - The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing.
- Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).
@@ -93,7 +93,7 @@ slot scope 是闭集 `root | session-maybe | session`
- `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId``useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry` - `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId``useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`
- `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。
`conversation``session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例`conversation.session` 只承载严格 session 的 header/view。composer bar`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 `conversation``session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumbtabaction`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
- 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。 - 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。
- Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
2026-07-25-web-input-machine-and-slash-pipeline.md: 977df6508e1a1cd54cf1ddb469a6bfb835f60071 2026-07-25-web-input-machine-and-slash-pipeline.md: 39ef214a94fcd019f535fb60136d5dcc09b54e60
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: f70065c8b356b2ed5ca6ab317fbdeb5177f058fa 2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 9b0ca0cadbc5e0212048b165f0d60d567a5639ad
@@ -69,9 +69,9 @@ A trigger/menu/pick pipeline with zero knowledge of "commands":
### hub / facade: the resident shell and the strict-session input body ### hub / facade: the resident shell and the strict-session input body
- The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-slash or the command surfaces, input still sends and receives normally — graceful degradation. - The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-slash or the command surfaces, input still sends and receives normally — graceful degradation.
- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. - Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears.
- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. - The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout.
- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. - ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`: a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted.
- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. - Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists.
- When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current. - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current.
- The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee. - The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee.
@@ -93,9 +93,10 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
### The slot system ### The slot system
`conversation` is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The child slots are all declared by ui-conversation's conversation registration: `conversation` is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The root registration renders the header outlet above its resident scrollport and the body outlet inside it, before the resident composer seat. The child slots are all declared by ui-conversation's conversation registration:
- `conversation.session` (single) — the strict-session header, view ring, and chat store; rebuilt when the session id switches. - `conversation.session.header` (single) — strict-session breadcrumb, view tabs, and header actions above the resident scrollport.
- `conversation.session` (single) — the strict-session view ring and draft mirror inside the resident scrollport. Header and body share the same session-scoped chat store; each is rebuilt when the session id switches.
- `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking textarea DOM survival. - `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking textarea DOM survival.
- `conversation.input.overlay` — the floating-overlay anchor inside the input card; registrants' inject resolves each one's own per-session controller by the slot sessionId. - `conversation.input.overlay` — the floating-overlay anchor inside the input card; registrants' inject resolves each one's own per-session controller by the slot sessionId.
- `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`.
@@ -128,7 +129,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ
## Consequences ## Consequences
- One resident conversation shell carries no-session/blank/active: no session → blank guarantees only the outer frame's React identity, allowing the disabled textarea to be replaced by the strict InputBar; the same blank session → engaging/active keeps the InputBar and the textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. - One resident conversation shell carries no-session/blank/active: no session → blank preserves ConversationRoot, Hero, the root-scoped Workspace picker, scrollport, composer seat, InputBar, and textarea; only the strict header and body outlets gain content. The same blank session → engaging/active also keeps the InputBar and textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer.
- The input surface's zero knowledge of commands plus optional dependencies: pure input works without the command packages; `@` references and skill references get free reuse of the same menu/pick pipeline. The cost is that space/enter adjudication is a per-source polling protocol whose answer semantics (sync/async, the meaning of undefined) are a frozen contract. - The input surface's zero knowledge of commands plus optional dependencies: pure input works without the command packages; `@` references and skill references get free reuse of the same menu/pick pipeline. The cost is that space/enter adjudication is a per-source polling protocol whose answer semantics (sync/async, the meaning of undefined) are a frozen contract.
- Transactionalized submission (attempt seq + the drift guard) makes the three defect classes — stale-result backwash, session switching, concurrent replay — structurally impossible, pinned by the matrix tests. - Transactionalized submission (attempt seq + the drift guard) makes the three defect classes — stale-result backwash, session switching, concurrent replay — structurally impossible, pinned by the matrix tests.
- Known gaps: chip fidelity across refresh (paste matching is reusable for it) has no workstream yet; the subagent reference's model representation awaits its business workstream. - Known gaps: chip fidelity across refresh (paste matching is reusable for it) has no workstream yet; the subagent reference's model representation awaits its business workstream.
@@ -69,9 +69,9 @@ occurrence 表与 chip 三投影:
### hub / facade:常驻外壳与严格 session 输入体 ### hub / facade:常驻外壳与严格 session 输入体
- hubtrigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-slash/命令面时输入正常收发,优雅降级。 - hubtrigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-slash/命令面时输入正常收发,优雅降级。
- 每个实体 Session 只有一个 `SessionInputShell`facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。 - 每个实体 Session 只有一个 `SessionInputShell`facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seatSession 出现后,彼此独立的严格 session header 和 body outlet 只填入这些固定区域。
- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。 - composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。
- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Herosidebar 的 blank 位只在 prompt 成功受理后翻 false。 - ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`summary 已证实为空的 Session 在任何 open state 下都保持 Hero,未经证实的 Session 则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Herosidebar 的 blank 位只在 prompt 成功受理后翻 false。
- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 - 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
- blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。
- Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。 - Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。
@@ -93,9 +93,10 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把
### slot 体系 ### slot 体系
`conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 sessionHero Workspace picker 保持 root。子 slot 均由 ui-conversation 的 conversation 注册声明: `conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 sessionHero Workspace picker 保持 root。root 注册把 header outlet 渲染在常驻 scrollport 上方,把 body outlet 渲染在其内部、常驻 composer seat 之前。子 slot 均由 ui-conversation 的 conversation 注册声明:
- `conversation.session`single)——严格 session 的 header、view ring 与 chat storesession id 切换时重建 - `conversation.session.header`single)——常驻 scrollport 上方严格 session 的 breadcrumb、view tab 与 header action
- `conversation.session`single)——常驻 scrollport 内严格 session 的 view ring 与 draft mirror。header 和 body 共享同一个 session scope chat storesession id 切换时各自重建。
- `conversation.composer.bar`single)——InputBar 本体的 slotInputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。 - `conversation.composer.bar`single)——InputBar 本体的 slotInputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。
- `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自 per-session controller。 - `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自 per-session controller。
- `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。
@@ -128,7 +129,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把
## 后果 ## 后果
- 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 只保证大框架 React identity,允许 disabled textarea 替换为严格 InputBar同一 blank session → engaging/active 保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。 - 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与 textarea;只有严格 session header 和 body outlet 开始承载内容。同一 blank session → engaging/active 保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。
- 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;`@` 引用与 skill 引用免费复用同一菜单/pick 管线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结契约。 - 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;`@` 引用与 skill 引用免费复用同一菜单/pick 管线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结契约。
- 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。 - 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。
- 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。 - 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md
2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41
2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f
@@ -0,0 +1,67 @@
# Agent Note: pi-ai routes are declared providers, not catalog lookups
Status: implemented
English | [中文](2026-08-03-pi-ai-declared-provider-catalog.zh.md)
## Problem
`dsh-llm-pi-ai` treated the pi-ai package's generated catalog as the boundary of what could be configured. A route key had to name an installed provider (`resolveProfiles` rejected anything else), model listing returned `getBuiltinModels(provider)` verbatim, and request-time model resolution looked the id up in that same catalog and overrode only `baseURL`. Three consequences followed, and all three were dead ends rather than gaps: an OpenAI-compatible gateway, a self-hosted server, or a provider newer than the installed catalog could not be configured at all; a model the catalog had not caught up with failed with `UNKNOWN_MODEL` even against a correct endpoint; and a model's context window and output cap were whatever the pinned pi-ai release said, so a deployment could neither correct a stale value nor supply one for a model pi-ai had never described. Upgrading the package was the only way to move any of it.
The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/compat`, an entry point whose own module documentation declares it a temporary compatibility surface — its catalog reads are `@deprecated`, and it is deleted when pi-ai finishes its `ModelManager` migration. The three configuration limits and the deprecated dependency have the same fix, because pi-ai's supported runtime (`createModels()` / `createProvider()`) is built around a provider being *declared* rather than looked up.
## Decision
A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it:
- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already.
- `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused.
- `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`.
- A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked.
### Snapshots, not a shared collection
`Models.streamSimple()` resolves its provider lazily, when the returned stream is first consumed — which is after the adapter has awaited the route's credential. A single collection mutated in place would therefore let a request that started under one configuration finish under another, or fail on a provider that no longer exists, even though `llm.prepareCall()` already froze that step's config and captured its adapter registration. A configuration change builds a *new* collection and leaves the one in use alone, so the seam's per-step freeze holds all the way down: switching models mid-reply takes effect on the next step, never inside the one in flight.
### The directory replaces atomically
The configurable-provider directory follows the profiles, so it changes whenever a declared route appears or leaves. Withdrawing the old registration and making a new one cannot express that: a candidate set the registry refuses — a profile keyed `deepseek-official`, which `llm-deepseek` already declares — would leave this plugin's whole directory withdrawn and the Models page empty, silently, because the settings-change callback contains the failure. `registerConfigurableProviders` therefore returns a handle carrying `replace(entries)` with the same validate-the-candidate-set-first atomicity `registerAdapter` has, and the plugin uses it. A refused swap costs a diagnostic; the previous entries keep serving.
Resolution fails loud and names the route and model at fault: a model the catalog does not describe falls back to the route's own `defaultContextWindow`/`defaultMaxTokens`, so a listing that discloses nothing but ids still yields a serviceable route; a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. Because the built `Provider` is part of the resolution result, a protocol or model error keeps the last good route set serving, exactly as a bad settings snapshot already did.
The configurable-provider directory is now the installed catalog **joined with** every route the current profiles declare, re-registered when that set changes. Without the join a hand-declared route would have no settings address and no configuration surface could show or edit it.
### A capability whose only level does nothing is reported unavailable
pi-ai reports a model with no reasoning metadata as supporting the single level `off`, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: `off` becomes an *omitted* reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows `off` selected.
`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives.
### Credentials stay outside pi-ai
pi-ai's `Models` carries its own credential concept — a `CredentialStore` keyed by provider id, with `envApiKeyAuth` resolving `credential.key ?? env(VAR)`. Adopting it would have created a second credential source of truth beside `ctx.credentials` and, worse, reintroduced the ambient fallback the harness deliberately forbids: a named-but-missing `apiKeyEnv` must fail with `MISSING_CREDENTIAL` rather than authenticate with whatever unrelated key the environment holds.
`ModelsImpl.applyAuth` honours `options.apiKey` as the request's key, but only through a provider that declares an api-key method: `resolveProviderAuth` short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery, returning nothing — and so failing the request with `Provider is not configured` — when the provider has no api-key method at all. The harness therefore resolves the route's key through its own seam, as before, and passes the result as the request's `apiKey`; the collection is constructed with no credential store.
A route's auth follows from that. A catalog route keeps the installed provider's own `auth`, which preserves provider-native ambient discovery for a profile naming no credential, and keeps it through an `api` override too: which environment a provider reads is a property of the provider, not of the wire format its models speak. The exception is a catalog provider with no api-key method — `openai-codex` authenticates through OAuth alone — where a profile that names a credential also gets the harness method beside the provider's own, because otherwise its configured key would be refused before any request went out. A keyless profile on such a route adds nothing and keeps the honest refusal: this adapter holds no OAuth store to resolve through. A hand-declared route gets a harness-owned `ApiKeyAuth` that reports configured-but-keyless rather than unconfigured, leaving the requirement to the protocol — which is where it lives: pi-ai's OpenAI-compatible implementation still demands a key or an `Authorization` header, and says so itself.
## Alternatives considered
- **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports.
- **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations.
- **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working.
- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported.
- **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it.
- **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity.
- **A runtime dynamic catalog** — `fetchModels` plus `ModelsStore`, refreshed in the background. Rejected for this change: it makes the model list external mutable state needing cache, invalidation, and an offline path, and the product need is a one-shot discovery action whose result the user adopts into `settings.yaml`. That action belongs to the configuration surface and is deferred with it; `settings.yaml` stays the single source of truth for what a route serves.
## Consequences
Configuring a provider no longer depends on a pi-ai release. A gateway, a self-hosted server, or a model newer than the pinned catalog is a `settings.yaml` edit, and a stale context window can be corrected in place. The deprecated `/compat` import is gone, so pi-ai deleting it is no longer a breaking event. `defaultMaxTokens` now flows from configuration when a deployment states one, without inventing a cap from catalog metadata.
What it costs: `settings.yaml` grows for a declared route, because it must state its endpoint, protocol, and model ids. `api` applies to a whole route, so a mixed-protocol catalog route cannot host a model of the other protocol — splitting it across two route keys is the workaround. Nothing queries a provider's `/models`, so a model list is only as current as its last edit. Reported error shape shifts in one case: a route whose auth resolves to nothing now surfaces pi-ai's own diagnostic as an error `finish` chunk before any network call, where the previous adapter sent a keyless request and surfaced the provider's 401.
## Testing
`tests/catalog.spec.ts` covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its `Authorization`-header workaround, an OAuth-only catalog route authenticating with the key its profile names while a keyless one stays unconfigured, a repointed route keeping its catalog auth, and every resolution failure that names a route or model. `tests/catalog.spec.ts` also pins the snapshot and directory contracts: an in-flight request whose route set changes during its credential await still reaches the endpoint it resolved against, the next request picks up the new one, a colliding declared route leaves the directory whole, and a declared route's entry appears and leaves with its profile. `packages/llm/llm/tests/topology.spec.ts` covers `replace` — refusing a candidate another registration owns while keeping the current set, accepting a swap over its own entries, allowing an empty set, and failing after disposal. `tests/sdk-options.spec.ts` re-targets the SDK boundary from the removed `/compat` import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's [design-verification role](2026-06-13-twin-llm-adapters.md) is unchanged.
@@ -0,0 +1,67 @@
# Agent Note: pi-ai 路由是被声明的提供方,而不是 catalog 查表
Status: implemented
[English](2026-08-03-pi-ai-declared-provider-catalog.md) | 中文
## Problem
`dsh-llm-pi-ai` 把 pi-ai 包生成的 catalog 当成了可配置范围的边界。路由键必须点名一个已安装提供方(`resolveProfiles` 拒绝其余一切),模型列举原样返回 `getBuiltinModels(provider)`,请求期的模型解析又在同一份 catalog 里查这个 id、且只覆盖 `baseURL`。由此产生三个后果,而且三个都是死路而非缺口:OpenAI 兼容网关、自建服务,或比已安装 catalog 更新的提供方,根本无法配置;catalog 尚未跟上的模型即便端点正确也会以 `UNKNOWN_MODEL` 失败;模型的上下文窗口与输出上限完全由锁定的 pi-ai 版本决定,部署既无法更正过期值,也无法为 pi-ai 从未描述过的模型补上。要动其中任何一条,只能升级依赖。
适配器还经 `@earendil-works/pi-ai/compat``streamSimple` 发起流式请求,而该入口自己的模块文档声明它是临时兼容面——其 catalog 读取标了 `@deprecated`,并会在 pi-ai 完成 `ModelManager` 迁移时被删除。这三条配置限制与这个废弃依赖的解法是同一个,因为 pi-ai 受支持的运行时(`createModels()` / `createProvider()`)正是围绕「提供方是被*声明*出来的,而非查出来的」建立的。
## Decision
提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`
- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id``name``contextWindow``maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。
- `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 BedrockSigV4 加 region)、Vertexproject、location、ADC)、Azure(提供方环境加 api-version)与 CodexOAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。
- `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。
- 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。
### 快照,而不是共享集合
`Models.streamSimple()` 惰性解析 provider——在返回的流首次被消费时,而那已在适配器 await 路由凭据之后。因此就地改动的单一集合,会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider,尽管 `llm.prepareCall()` 早已冻结了该步的 config 并捕获了其适配器注册。配置变化改为构造**新**集合,正在被使用的那个原封不动,于是 seam 的每步冻结得以贯通到底:回复途中切换模型在下一步生效,绝不影响在途的那一步。
### 目录原子替换
可配置提供方目录跟随 profiles,因此每当一条声明路由出现或离开它都会变化。「撤销旧注册再新建一个」表达不了这件事:注册表拒绝的候选集合——比如一份键为 `deepseek-official` 的 profile,而 `llm-deepseek` 已声明了它——会让本插件的整个目录被撤走、Models 页变空,而且是静默的,因为 settings 变更回调把失败容住了。因此 `registerConfigurableProviders` 改为返回带 `replace(entries)` 的句柄,其「候选集先整体校验」的原子性与 `registerAdapter` 相同,插件改用它。被拒的替换只付出一条诊断;先前的条目继续服务。
解析失败得响亮,并点名出问题的路由与模型:catalog 未描述的模型会回落到该路由自己的 `defaultContextWindow``defaultMaxTokens`,因此只公布 id 的列表也能得到可服务的路由;catalog 未提供的路由需要 `api``baseURL` 和非空的 `models` 列表。由于构造出的 `Provider` 是解析结果的一部分,协议或模型出错时最后可用的路由集合会继续服务——与此前坏的 settings 快照的行为完全一致。
可配置提供方目录现在是已安装 catalog **与**当前 profile 声明的每条路由的并集,并在该集合变化时重新登记。没有这个并集,手工声明的路由就没有 settings 地址,任何配置界面都无法展示或编辑它。
### 唯一档位什么也做不到的能力,报告为不可用
pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:`off` 在派发时变成被*省略*的 reasoning 选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 `off` 已选中。
因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖每一个手工声明的模型**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。
### 凭据留在 pi-ai 之外
pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `CredentialStore`,配合 `envApiKeyAuth` 解析 `credential.key ?? env(VAR)`。采用它会在 `ctx.credentials` 之外制造第二个凭据事实源,更糟的是会把 harness 明确禁止的环境回落重新引进来:点名了却取不到的 `apiKeyEnv` 必须以 `MISSING_CREDENTIAL` 失败,而不是用环境里恰好持有的某个无关密钥完成认证。
`ModelsImpl.applyAuth` 会把 `options.apiKey` 当作该请求的密钥,但这条路必须经由一个声明了 api-key 方法的提供方:`resolveProviderAuth` 在覆盖存在时短路到该方法,否则依次落到凭据存储与环境发现;若提供方压根没有 api-key 方法,它返回空,请求随即以 `Provider is not configured` 失败。因此 harness 一如既往经自身 seam 解析路由密钥,并把结果作为请求的 `apiKey` 传入;该集合构造时不带任何凭据存储。
路由的 auth 由此推出。catalog 路由保留已安装提供方自己的 `auth`,从而为不点名凭据的 profile 保住其提供方原生环境发现,且在 `api` 覆盖之下同样保留:提供方读哪个环境是提供方自身的属性,而非其模型所讲协议格式的属性。例外是没有 api-key 方法的 catalog 提供方——`openai-codex` 只走 OAuth——此时点名了凭据的 profile 会在提供方原有 auth 之外再获得 harness 的方法,否则它配置的密钥会在任何请求发出之前被拒。这类路由上不点名凭据的 profile 什么也不加、并保留那句诚实的拒绝:本适配器没有可供解析的 OAuth 存储。手工声明的路由则获得一个 harness 自有的 `ApiKeyAuth`,它报告「已配置但无密钥」而非「未配置」,把该要求留给协议——那才是它真正所在的位置:pi-ai 的 OpenAI 兼容实现仍要求密钥或 `Authorization` 标头,并且会自己说出来。
## Alternatives considered
- **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider``auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。
- **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。
- **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。
- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap``compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。
- **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。
- **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。
- **运行时动态 catalog**——`fetchModels``ModelsStore`,后台刷新。本次变更拒绝:它把模型列表变成需要缓存、失效与离线路径的外部可变状态,而产品需求是一次性的发现动作、其结果由用户采纳进 `settings.yaml`。该动作属于配置界面,与之一并暂缓;`settings.yaml` 始终是「路由服务什么」的唯一事实源。
## Consequences
配置一个提供方不再取决于 pi-ai 的发布节奏。网关、自建服务,或比锁定 catalog 更新的模型,都是一次 `settings.yaml` 编辑,过期的上下文窗口也能就地更正。废弃的 `/compat` 导入已经消失,因此 pi-ai 删除它不再是破坏性事件。`defaultMaxTokens` 现在只在部署明确给出时才自配置流出,不会从 catalog 元数据里发明一个上限。
代价是:声明式路由会让 `settings.yaml` 变长,因为它必须自报端点、协议与模型 id。`api` 作用于整条路由,因此混合协议的 catalog 路由无法承载另一种协议的模型——把它拆成两个路由键是变通办法。没有任何环节查询提供方的 `/models`,因此模型列表的新鲜度只到最近一次编辑为止。有一种情形下报错形状发生变化:auth 解析不出任何值的路由,现在会在任何网络调用之前把 pi-ai 自己的诊断作为错误 `finish` 分片呈现,而此前的适配器会发出无密钥请求并呈现提供方的 401。
## Testing
`tests/catalog.spec.ts` 针对本地 mock 服务器端到端覆盖该契约:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 `Authorization` 标头变通、只走 OAuth 的 catalog 路由用 profile 点名的密钥完成认证而无密钥者保持未配置、改指协议的路由保留其 catalog auth,以及每一种点名路由或模型的解析失败。`tests/catalog.spec.ts` 还钉住了快照与目录两项契约:在途请求即便其路由集在 credential await 期间改变,仍抵达它解析时对应的端点;下一个请求取用新配置;冲突的声明路由让目录保持完好;声明路由的条目随其 profile 出现与离开。`packages/llm/llm/tests/topology.spec.ts` 覆盖 `replace`——拒绝他人已拥有的候选同时保住当前集合、接受对自身条目的替换、允许空集合,以及 dispose 之后失败。`tests/sdk-options.spec.ts` 把 SDK 边界从已移除的 `/compat` 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的[设计验证角色](2026-06-13-twin-llm-adapters.md)不变。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md
2026-08-04-declaring-a-provider-from-the-models-page.md: 53996e488c467e00754837b83b7a33994d4813ee
2026-08-04-declaring-a-provider-from-the-models-page.zh.md: fa61c48492eabf51f3d325078ceffa84ac52d12c
@@ -0,0 +1,43 @@
# Agent Note: Declaring a provider from the Models page
Status: implemented
English | [中文](2026-08-04-declaring-a-provider-from-the-models-page.zh.md)
## Problem
The two layers below made a pi-ai route [a declaration](2026-08-03-pi-ai-declared-provider-catalog.md) and gave the host a way to [interrogate a draft endpoint](2026-08-04-draft-provider-endpoint-interrogation.md). Neither reached a person who does not edit YAML: the Models page still offered one API-key field per provider and a fold with a base URL, so adding a gateway meant opening `$DSH_HOME/settings.yaml` and knowing the profile shape, and correcting a stale context window meant the same. The capability existed and the surface did not expose it.
Two things were missing, and they are not the same shape. Editing an existing route's models is a *field* on a card that already exists. Declaring a route is a *create*: the route id is being chosen, so until it is chosen there is no settings address to edit.
## Decision
The model list is a component shared by both flows; the create is its own card.
`ModelListEditor` edits a profile's `models` array — one row per model with id, display name, context window, and output cap — and owns the fetch action. An empty list means "serve this route's built-in catalog", so a row is only ever added deliberately; clearing an optional field drops it rather than storing a value the schema would reject, and a capacity that is not a positive integer is not stored at all.
Fetching asks about the endpoint **the form currently shows** — a base URL edited but unsaved, a key typed but unstored — so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end; the adapter's own message appears beside rows that stay editable by hand.
`CustomProviderCard` declares a route pi-ai does not ship. It is a separate card because the route id is chosen here: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. The three facts a hand-declared route cannot default — endpoint, protocol, and at least one model — gate the create button, so a failure names the field while the user is still looking at it.
The protocol choices come from the namespace's **own schema**, read through the settings descriptor the page already fetches (`providers.*.api` is a union of the adapter's `supportedProtocols()`). No new wire field, no constant in the client, and no way for the offered choices to drift from the accepted ones.
## Alternatives considered
**Declare a provider through `ProviderEditor` with extra fields.** One card instead of two, but the editor is addressed by `settingsPath`, and a route being named has no path yet. Recomputing the path per keystroke would remount the card and discard the draft; deferring it would mean the editor's whole write path no longer described what it was editing.
**Add a wire field for the protocol list.** Explicit, and the obvious first instinct. But the settings schema already crosses the wire and already contains the union, so a second copy could disagree with the first — and the one the adapter enforces is the schema.
**Fetch against the stored profile instead of the live form.** No key would leave the form for an unsaved provider. But the flow that needs fetching most is the one where nothing is stored yet, and a form whose endpoint was edited would quietly interrogate the old one.
**Write adopted candidates straight into the list.** Fewer clicks, but a fetch would then overwrite capacities the user had corrected, and a listing that discloses only ids would replace real numbers with nothing.
## Consequences
A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list.
What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is the one namespace whose profiles describe a whole provider — a `llm-deepseek` route stays a composition fact. Interrogation reaches only OpenAI-compatible endpoints, so a gateway speaking another protocol reports that it cannot be asked and its models are typed in. And the page now holds a key in component state for the duration of a fetch, which is the same exposure `credentials.set` already has and no longer than the card lives.
## Testing
`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not.
@@ -0,0 +1,43 @@
# Agent Note: 在 Models 页上声明一个提供方
Status: implemented
[English](2026-08-04-declaring-a-provider-from-the-models-page.md) | 中文
## Problem
下面两层已经让 pi-ai 路由变成[一份声明](2026-08-03-pi-ai-declared-provider-catalog.md),并给了 host [询问草稿端点](2026-08-04-draft-provider-endpoint-interrogation.md)的能力。但两者都没有抵达不编辑 YAML 的人:Models 页仍然只为每个提供方提供一个 API 密钥输入框和一个装着 API 地址的折叠区,因此接入一个网关意味着打开 `$DSH_HOME/settings.yaml` 并知道 profile 的形状,更正一个过期的上下文窗口也是如此。能力已经存在,界面却没有暴露它。
缺的是两件事,而它们的形状并不相同。编辑既有路由的模型,是一张已经存在的卡片上的一个*字段*;声明一条路由则是一次*创建*:路由 id 正在此处被选定,而在选定之前根本没有可编辑的 settings 地址。
## Decision
模型列表是两条流程共用的组件;创建则是它自己的卡片。
`ModelListEditor` 编辑 profile 的 `models` 数组——一行一个模型,含 id、显示名称、上下文窗口与输出上限——并持有获取动作。空列表意味着「使用该路由的内置 catalog」,因此每一行都只会被刻意添加;清空某个可选字段会丢弃它,而不是存入一个 schema 会拒绝的值,不是正整数的容量则根本不会被存下。
获取会询问表单**当前显示**的端点——已修改但未保存的 API 地址、已键入但未存储的密钥——因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路;适配器自己的消息会出现在各行旁边,而这些行仍可手工编辑。
`CustomProviderCard` 声明 pi-ai 未提供的路由。它之所以是独立卡片,正因为路由 id 是在这里选定的:一次 `settings.mutate``providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的三件事——端点、协议、至少一个模型——会门控创建按钮,因此失败会在用户仍看着该字段时点名它。
协议选项来自该 namespace **自己的 schema**,经页面本就会获取的 settings 描述符读出(`providers.*.api` 是适配器 `supportedProtocols()` 的一个 union)。没有新增协议字段,客户端里没有常量,提供的选项也无从与被接受的集合发生漂移。
## Alternatives considered
**在 `ProviderEditor` 上加字段来声明提供方。** 两张卡片变一张,但编辑器由 `settingsPath` 寻址,而正在被命名的路由还没有路径。逐次按键重算路径会让卡片重新挂载并丢掉草稿;推迟计算则意味着编辑器的整条写入路径不再描述它正在编辑的东西。
**为协议列表新增一个协议字段。** 显式,也是最直觉的第一反应。但 settings schema 本来就会跨越协议层、本来就含有那个 union,因此第二份副本可能与第一份不一致——而适配器强制执行的是 schema 那一份。
**针对已存 profile 而非实时表单发起获取。** 对尚未保存的提供方来说,密钥就不会离开表单。但最需要获取的恰恰是「什么都还没存」的那条流程,而端点已修改的表单会悄悄去询问旧地址。
**把采纳的候选直接写进列表。** 点击更少,但一次获取就会覆盖用户已更正的容量,而只公布 id 的列表会把真实数字替换成空。
## Consequences
网关、自建服务,或比已安装 catalog 更新的模型,如今无需离开浏览器就能配置,而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器;编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表。
代价是:只有 pi-ai 路由可以手工声明,因为 `llm-pi-ai` 是唯一一个其 profile 描述整个提供方的 namespace——`llm-deepseek` 路由仍是组合面的事实。询问只覆盖 OpenAI 兼容端点,因此讲其他协议的网关会报告自己无法被询问,其模型需手工键入。另外,页面在一次获取期间会把密钥保存在组件状态里,这与 `credentials.set` 已有的暴露面相同,且不长于卡片的存活时间。
## Testing
`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
2026-08-04-draft-provider-endpoint-interrogation.md: 65545098cd1063c40081481c1ac8f0afdb4fb390
2026-08-04-draft-provider-endpoint-interrogation.zh.md: cb09042904f4ab1558c0c214d275a934234955ac
@@ -0,0 +1,50 @@
# Agent Note: Interrogating a draft provider endpoint
Status: implemented
English | [中文](2026-08-04-draft-provider-endpoint-interrogation.zh.md)
## Problem
Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding an OpenAI-compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and most of these endpoints do publish that list at `GET /models`.
The obvious answer, a dynamic runtime catalog refreshed in the background, was rejected with the layer below it: it makes a route's model list external mutable state needing a cache, an invalidation story, and an offline path, while the product need is narrower. What is needed is a *question asked once*, whose answer the user adopts into `settings.yaml` — so `settings.yaml` remains the only thing deciding what a route serves.
The awkward part is that the question is about something that does not exist yet. The provider being added has no route, no stored profile, and no stored credential; the endpoint and key are values in a form the user is still typing. Every existing seam operation is keyed by a registered provider route, so none of them can carry this.
## Decision
Interrogation is keyed by **settings namespace**, not by provider route:
- `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns, and `ctx.llm.discoverModels(settingsNs, request)` asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name.
- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path writes settings or credentials. The one read is the credential of a route the request names: a configuration surface holds a redacted descriptor rather than the stored secret, so the draft's `apiKey` is present only while the user is typing one, and without that read an already-configured route would be interrogated unauthenticated and answer 401. The typed key wins, being the one under test.
- `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires.
- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. The method is loopback-only for a second reason besides the key: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which is a probe an anonymous LAN caller must not have. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered.
`dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs.
### Why not pi-ai's own refresh machinery
pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `ModelsStore`, and the layer below already builds pi-ai `Provider` objects. Routing interrogation through them would have meant constructing a throwaway provider and collection per question, with a store whose entire purpose — persisting a catalog across runs — contradicts the decision that `settings.yaml` owns the catalog. It would also have bought nothing: **no built-in pi-ai provider implements `fetchModels`**, so the HTTP call and its response parsing are this package's code either way. A direct fetch says what is actually happening. The route's stored credential is resolved by the plugin's own per-request resolver, and only on the branch that reaches the network, so a catalog route answers without touching credentials and never fails over one the question did not need.
## Alternatives considered
**Key interrogation by provider route.** Symmetric with every other seam operation, and it would let the request omit the endpoint. But the case that motivates the feature — adding a provider — has no route, so the operation would only work for providers already configured, which are the ones that need it least.
**Put the capability on `LlmAdapter`.** Adapters are reached through a route registration, so this has the same problem, plus it would make an adapter instance answer questions about endpoints it does not serve.
**Have the host read the stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. Accepting the draft keeps what the user sees and what is asked identical — with the credential as the one exception, because it is the one field a surface is never shown and so can never put in the draft.
**Interrogate every pi-ai protocol.** Anthropic's listing happens to share OpenAI's envelope, and Google's does not. Supporting the ones that are easy would make coverage arbitrary and, worse, make a wrong guess at a response shape indistinguishable from a provider with no models. A protocol that says it cannot be interrogated sends the user to hand-entry, which is the documented fallback.
**Buffer the reply with `response.text()` and check its length.** Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed.
## Consequences
A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber.
What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage is protocol-shaped rather than provider-shaped — an Anthropic-compatible gateway must be filled in by hand even though its listing would parse. And because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately.
## Testing
`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/host/apiproxy/tests/api-proxy-config.spec.ts` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error.
@@ -0,0 +1,50 @@
# Agent Note: 询问草稿中的提供方端点
Status: implemented
[English](2026-08-04-draft-provider-endpoint-interrogation.md) | 中文
## Problem
当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.md)之后,要接入一个 OpenAI 兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而这类端点大多在 `GET /models` 上公布了这份列表。
显而易见的答案——后台刷新的运行时动态 catalog——已随下层一并被拒绝:它会把路由的模型列表变成需要缓存、失效语义与离线路径的外部可变状态,而产品需求要窄得多。真正需要的是**只问一次**,其答案由用户采纳进 `settings.yaml`——从而让 `settings.yaml` 始终是唯一决定路由服务什么的东西。
麻烦之处在于,被问的对象还不存在。正在新增的提供方没有路由、没有已存 profile、也没有已存凭据;端点与密钥都是用户尚在输入的表单值。而现有的每个 seam 操作都以已注册的提供方路由为键,因此没有一个能承载它。
## Decision
询问以 **settings namespace** 为键,而不是提供方路由:
- `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,`ctx.llm.discoverModels(settingsNs, request)` 发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。
- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider``baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径不写 settings 与 credentials。唯一的读取是请求所点名路由的凭据:配置界面拿到的是脱敏描述符而非已存的机密,因此草稿里的 `apiKey` 只在用户正键入时才存在;没有这次读取,已配置好的路由就会被不带认证地询问,只换回一个 401。键入的密钥优先,因为那正是被测试的那一把。
- `LlmDiscoveredModel``id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。
- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate``credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。除密钥之外它被钉在回环还有第二个理由:它让宿主向调用方选定的 URL 发起 GET 并回报结果,这是匿名 LAN 调用者不该拥有的探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。
`dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。
### 为什么不用 pi-ai 自己的 refresh 机制
pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()``ModelsStore`,而下层本来就在构造 pi-ai `Provider` 对象。把询问接到它们上面,意味着每问一次就要构造一个用完即弃的 provider 与集合,而那个 store 的全部目的——跨运行持久化 catalog——恰恰与「`settings.yaml` 拥有 catalog」的决定相抵触。而且它什么也换不来:**没有任何一个 pi-ai 内置 provider 实现了 `fetchModels`**,因此 HTTP 调用及其响应解析无论如何都是本包的代码。直接 fetch 才如实说出正在发生的事。路由已存的凭据由本插件自己那套逐请求解析器取出,且只在真正要联网的那条分支上进行,因此 catalog 路由作答时既不触碰凭据,也不会因为一把这次询问根本用不上的密钥而失败。
## Alternatives considered
**以提供方路由为键。** 与其他每个 seam 操作对称,也能让请求省去端点。但催生该功能的场景——新增提供方——没有路由,于是这个操作只对已配置好的提供方可用,而它们恰恰最不需要它。
**把能力挂在 `LlmAdapter` 上。** 适配器要经由路由注册才能抵达,因此问题相同;而且这会让一个适配器实例去回答它并不服务的端点的问题。
**让 host 读已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有 secret 跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。接受草稿让用户看见的与被询问的保持一致——凭据是唯一的例外,因为它是界面从不被展示、因而永远无法放进草稿的那个字段。
**询问 pi-ai 的每一种协议。** Anthropic 的列表恰好与 OpenAI 共用同一层信封,而 Google 的不是。只支持容易的那几种会让覆盖范围变得任意;更糟的是,猜错的响应形状会与「该提供方没有模型」无法区分。一个明说自己无法被询问的协议,会把用户送去手工填写——那正是既定的回退路径。
**用 `response.text()` 缓冲整个回复再判断长度。** 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。
## Consequences
接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、除 fiber 外没有生命周期。
代价是:协议层多了第三个承载 secret 的载荷,配置面的只写接口从两个方法变成三个。发现能力按协议而非按提供方划分——一个 Anthropic 兼容网关即便其列表能被解析,也仍须手工填写。而且由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。
## Testing
`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/host/apiproxy/tests/api-proxy-config.spec.ts` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md
2026-08-05-slot-declaration-injection.md: cb15125977c060144553d7cf75e3c2c26fb1b23b
2026-08-05-slot-declaration-injection.zh.md: 385cab875bb445ba1ca324fc9b45363b8daf50b6
@@ -0,0 +1,45 @@
# Agent Note: Slot declaration injection and reload lifetimes
Status: implemented
English | [中文](2026-08-05-slot-declaration-injection.zh.md)
## Problem
Client plugins may contribute to a slot before or after the plugin that declares it. Cordis service injection cannot express this dependency: a service is only an indirect ordering signal, client manifest dependency rows do not sequence activation, and a slot can disappear and return while every related service remains mounted. Registering immediately therefore races an undeclared slot, while waiting on an unrelated service couples independently reloadable features.
Slot-level hot replacement also requires two independent owners. Removing the declaring plugin must remove every contribution under its child slots; removing a contributing plugin must remove only that plugin's entries. A replacement declaration with the same key is a new lifetime even when disappearance and reappearance batch into one notification.
## Decision
`SlotsService.inject(name, callback)` makes the declared slot itself the dependency. The full `SlotMap` key is statically checked; there is no namespace builder, synthetic Cordis service, or slot-specific `Context`. The callback runs immediately when the declaration exists, otherwise waits, and returns either one synchronous disposer or a synchronous iterable of disposers. Iterable effects install transactionally: a later setup failure disposes every earlier yielded effect in reverse order.
The ledger records a declaration epoch distinct from the slot's ordinary entry version. An epoch changes whenever a child declaration is created or collapsed. Injection remembers the active epoch, disposes its callback effect when that epoch ends, and reruns the callback for a replacement declaration even when the final observed state is continuously declared. Ordinary contribution changes do not restart injection.
Both sides retain their natural ownership. The injection controller and every contribution run on the contributing plugin's caller `Context`, so disposing that plugin removes its wait and active entries. The slot ledger's existing child-collapse cascade removes entries when the declarer disappears; injection then runs their disposers to release service-layer resources and remains ready for a later declaration. The declaring plugin's `Context` is neither retained as a capability source nor exposed to contributors.
Dynamic reload code uses an ordinary Cordis plugin fiber as its replacement unit: activate the new module through `ctx.plugin()`, dispose and await the old fiber before mounting its replacement, and let its `slots.inject` and `slots.register` effects leave with that fiber. Renderer subscriptions observe the ledger removal and unmount the component; no slot-owned fiber tree is required.
## Failure and lifecycle contract
An injection whose declaration already exists reports callback setup failures synchronously. A callback failure after a delayed declaration first unsubscribes and rolls back its collected effects, then reports the failure outside the slot notification flush so one registrant cannot starve other listeners. Direct `slots.register()` into an undeclared slot continues to throw: injection is explicit and does not weaken load-time validation.
Disposing an injection is idempotent. It unsubscribes before releasing the active callback effect, preventing teardown-triggered ledger notifications from resurrecting the contribution. Declaration-bound teardown is synchronous with the ledger boundary, so it releases service-layer resources before any subsequent same-tick registration. A waiting injection disposed with its plugin cannot activate later.
## Alternatives considered
**Use `ConversationService` or another service as an ordering barrier.** Service presence does not identify the declaration or follow its reload lifetime, and it creates a false package dependency for presentation-only contributors.
**Bridge each declaration into a `slot:<name>` Cordis service.** This pollutes the service namespace, turns a misspelled dynamic key into a silent service wait, and disguises ledger state as a business capability. Native slot injection provides the same wait without changing Cordis topology.
**Create a Cordis context or fiber for every slot.** A contributor needs the intersection of its own plugin lifetime and the declaration lifetime, not the declarer's capabilities. A slot-owned context introduces capability inheritance and dual-parent teardown problems without improving ledger ownership.
**Make `register()` wait implicitly.** Immediate failure on an undeclared target is a valuable configuration check. Explicit injection distinguishes an intentional independently ordered contribution from a broken composition.
**Judge replacement from `spec(name) !== undefined` alone.** Collapse and redeclaration can batch into one continuously present final state while the old contributions have already been removed. The declaration epoch preserves that boundary.
## Consequences
Slot dependencies become auditable at the registration site and follow declaration replacement without package-specific ordering conventions. Dynamic plugin disposal removes rendered entries through existing Cordis effects, while declaration replacement has a stable hook for later slot-level HMR.
The runtime carries one additional monotonic epoch per touched slot and injection callbacks must return their cleanup. Multi-registration callbacks use iterable effects so setup and teardown remain atomic. The flat dotted-key ledger and the single `register()` composition authority remain unchanged.
@@ -0,0 +1,45 @@
# Agent Noteagent 决策记录):slot 声明注入与重载生命周期
Status: implemented
[English](2026-08-05-slot-declaration-injection.md) | 中文
## 问题
客户端插件可能在声明某个 slot 的插件之前或之后向该 slot 贡献内容。Cordis 服务注入无法表达这种依赖:服务只能作为间接的顺序信号;客户端 manifest(元数据清单)的依赖项不会规定激活顺序;即使所有相关服务始终挂载,slot 仍可能消失后重新出现。因此,立即注册会与尚未声明的 slot 形成竞态,而等待无关服务则会耦合本可独立重载的功能。
slot 级热替换还要求两个相互独立的所有者。移除声明方插件必须移除其子 slot 下的所有贡献;移除贡献方插件只能移除该插件自己的条目。即使消失与重新出现合并在同一次通知中,同一个 key 的替换声明也属于新的生命周期。
## 决策
`SlotsService.inject(name, callback)` 以已声明的 slot 本身作为依赖。完整的 `SlotMap` key 会经过静态检查;系统不引入命名空间构建器、合成的 Cordis 服务或 slot 专属 `Context`。声明存在时回调同步执行,否则等待;回调返回一个同步 disposer,或由多个 disposer 构成的同步 iterable。iterable effect 的安装具有事务性:后续 setup 失败时,系统会按逆序 dispose(资源释放)之前 yield 的所有 effect。
该账本记录独立于 slot 普通条目版本的 declaration epoch(声明代次)。每当子声明创建或折叠时,epoch 都会变化。注入会记住活跃 epoch;该 epoch 结束时,注入会 dispose 其回调 effect;即使最终观测到的状态始终为已声明,也会为替换声明重新执行回调。普通贡献变更不会重启注入。
声明方与贡献方各自保留其自然所有权。注入控制器和每项贡献都运行在贡献方插件调用时的 `Context` 上,因此 dispose 该插件会同时移除其等待与活跃条目。slot 账本现有的子项折叠级联会在声明方消失时移除条目;随后,注入会运行其 disposer 以释放服务层资源,并继续等待后续声明。系统既不会将声明方插件的 `Context` 保留为 capability 来源,也不会向贡献方公开它。
动态重载代码使用普通 Cordis 插件 fiber 作为替换单元:通过 `ctx.plugin()` 激活新模块;挂载替换模块之前,先 dispose 并等待旧 fiber;该 fiber 的 `slots.inject``slots.register` effect 会随之退出。renderer 订阅会观察到账本移除并卸载组件;无需建立 slot 自有的 fiber 树。
## 失败与生命周期契约
如果注入创建时声明已经存在,回调 setup 失败会同步上报。延迟声明出现后发生的回调失败,会先取消订阅并回滚已收集的 effect,再在 slot 通知刷新之外上报,避免一个注册方使其他 listener 得不到执行机会。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常:注入是显式机制,不会削弱加载时验证。
对注入执行 dispose 具有幂等性。它会先取消订阅,再释放活跃的回调 effect,避免拆卸触发的账本通知复活该项贡献。声明绑定的 teardown 与账本边界同步,因此会在同一 tick 的任何后续注册之前释放服务层资源。随插件一同 dispose 的待命注入无法在之后激活。
## 备选方案
**将 `ConversationService` 或其他服务用作顺序屏障。** 服务存在并不能标识相应声明,也不会跟随声明的重载生命周期;只负责呈现的贡献方还会因此产生虚假的包(package)依赖。
**将每项声明桥接为 `slot:<name>` Cordis 服务。** 这会污染服务命名空间,使拼错的动态 key 变成静默的服务等待,并把账本状态伪装成业务 capability。原生 slot 注入无需改变 Cordis 拓扑,即可提供同样的等待能力。
**为每个 slot 创建 Cordis 上下文或 fiber。** 贡献方需要的是自身插件生命周期与声明生命周期的交集,而不是声明方的 capability。slot 所有的上下文会引入 capability 继承和双父级拆卸问题,却无法改善账本所有权。
**让 `register()` 隐式等待。** 对未声明目标立即失败是一项有价值的配置检查。显式注入能够区分有意独立排序的贡献与错误组合。
**只根据 `spec(name) !== undefined` 判断替换。** 折叠与重新声明可以合并成一个最终状态始终存在的通知,而旧贡献此时已经被移除。declaration epoch 保留了这条生命周期边界。
## 影响
slot 依赖可以在注册点审计,并且无需特定于包的顺序约定即可跟随声明替换。动态插件 dispose 会通过既有 Cordis effect 移除已渲染条目,而声明替换则为后续 slot 级 HMR(热模块替换)提供稳定钩子。
运行时为每个被访问的 slot 多维护一个单调 epoch,且注入回调必须返回清理操作。多注册回调使用 iterable effect,使 setup 与 teardown 保持原子性。扁平的点分 key 账本和唯一的 `register()` 组合权威保持不变。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md
2026-08-06-agent-event-payload-objects.md: 470c8fb3f9282005829846307778d3d1088c3888
2026-08-06-agent-event-payload-objects.zh.md: ff201a7c3134c0ef809c9a798d65412541f9f1e7
@@ -0,0 +1,27 @@
# Agent Note: Agent-scoped events dispatch a single payload object
Status: implemented
English | [中文](2026-08-06-agent-event-payload-objects.zh.md)
## Problem
Agent-scoped events historically took positional arguments: a leading `agent` subject, event-specific fields, and a trailing `next` for waterfall/serial events. Adding a field or retiring a context type (as with `PreStepContext` and `RequestFailureContext`) rewrote every listener and emitter across packages, and the contract stayed spread across the parameter list instead of one named payload.
## Decision
Every agent-scoped event takes exactly one payload object as its first argument. The payload always carries the subject (`agent`), the event's fields, and the cancellation `signal` when the event has one; `next` remains the last argument of waterfall/serial events. The affected events are the twelve `agent/*` events, `agent-loop/config-start-failed` (the only one without a subject), and `goal/changed`.
`PreStepContext` and `RequestFailureContext` are retired; their fields live directly in the `agent/pre-step` and `agent/request-error` payloads.
Dispatch is fused: `agentEvents(ctx, agent)` (and the one-shot `emitAgentEvent`) injects the subject so the scope carrier key and the payload's `agent` cannot diverge, and the injected subject wins even over a structurally acceptable payload that happens to carry an `agent` field. `ReactLoopAgent` builds its dispatcher once in the constructor and routes every emit, serial, and waterfall through it, so hot-path dispatches allocate nothing.
## Alternatives considered
**Keep positional signatures.** Adding a field or retiring a context type would keep rewriting every listener and emitter, and the contract would stay spread across the parameter list instead of one named payload.
**Hand-build the subject at each dispatch site.** The loop's intermediate design called `ctx.waterfall(this.carrier, …)` with a manually constructed `{ agent: this, … }` payload; it avoided per-dispatch allocation but duplicated the subject injection and let the scope key and the payload subject diverge. The fused dispatcher is the single injection point for every dispatch mode.
## Consequences
Listener signatures name the full payload once, so extending a payload or retiring a context type is a one-shape change across all listeners and emitters. The subject/scope coupling is enforced by the dispatcher for every dispatch mode, and the loop's hot paths stay allocation-free.
@@ -0,0 +1,27 @@
# Agent Note: Agent 作用域事件 dispatch 单个 payload 对象
Status: implemented
[English](2026-08-06-agent-event-payload-objects.md) | 中文
## 问题
Agent 作用域事件历来采用位置参数:开头的 `agent` 主体、事件专属字段,以及末尾用于 waterfall(瀑布式事件)/serial 事件的 `next`。新增字段或退役上下文类型(如 `PreStepContext``RequestFailureContext`)都会迫使跨包重写每个监听器和 emitter,契约也一直分散在参数列表中,而不是集中在一个具名 payload 中。
## 决策
每个 agent 作用域事件都将恰好一个 payload 对象作为其第一个参数。payload 始终携带主体(`agent`)、事件的字段,以及事件有取消信号时的取消 `signal``next` 仍然是 waterfall/serial 事件的最后一个参数。受影响的事件是十二个 `agent/*` 事件、`agent-loop/config-start-failed`(唯一没有主体的事件)以及 `goal/changed`
`PreStepContext``RequestFailureContext` 已退役;它们的字段直接存在于 `agent/pre-step``agent/request-error` 的 payload 中。
dispatch 是融合的:`agentEvents(ctx, agent)`(以及一次性 `emitAgentEvent`)注入主体,使作用域载体键与 payload 的 `agent` 不可能分叉;即使某个结构上可接受的 payload 恰好携带 `agent` 字段,注入的主体仍然优先。`ReactLoopAgent` 在构造函数中构建一次 dispatcher,并将每个 emit、serial 和 waterfall 都经由它路由,因此热路径上的 dispatch 不产生任何分配。
## 考虑过的替代方案
**保留位置签名。** 新增字段或退役上下文类型依旧会重写每个监听器和 emitter,契约也会继续分散在参数列表中,而不是集中在一个具名 payload 中。
**在每个 dispatch 位置手工构造主体。** loop 的中间设计调用 `ctx.waterfall(this.carrier, …)`,传入手工构造的 `{ agent: this, … }` payload;它避免了每次 dispatch 的分配,却重复了主体注入,并让作用域键与 payload 主体分叉。融合的 dispatcher 是每种 dispatch 模式的唯一注入点。
## 后果
监听器签名一次性命名完整 payload,因此扩展 payload 或退役上下文类型,对所有监听器和 emitter 都是一次形状变更。主体/作用域耦合由 dispatcher 在每种 dispatch 模式下强制执行,且 loop 的热路径保持零分配。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md
2026-08-06-web-markdown-incremental-ast-renderer.md: 3599bfcc78dc4eefe5e82f461a469bdba15f3aae
2026-08-06-web-markdown-incremental-ast-renderer.zh.md: 2e00977da58ef29a77c45abcecf0f3bb62737929
@@ -0,0 +1,33 @@
# Agent Note: Incremental streaming markdown through a direct mdast renderer
Status: implemented
English | [中文](2026-08-06-web-markdown-incremental-ast-renderer.zh.md)
## Problem
`MarkdownText` re-parsed the whole accumulated reply on every streaming publish: react-markdown's string-only API builds a fresh unified processor per render and runs micromark → mdast → hast → React over the full text, so per-chunk main-thread work grew linearly with the reply and the stream's cumulative cost grew quadratically. The existing mitigations (frame batching, the isolated streaming tail, the plain fence arm) bounded how often and how widely that work ran, never how much text each run re-parsed. Fixing it needs AST-level input — freezing settled blocks and re-parsing only the source tail — which the string-only wrapper structurally cannot express.
## Decision
`MarkdownText` renders mdast directly and parses incrementally while streaming:
- **Grammars** ([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)): `parseGfm` (streaming arm and `extractMarkdownPlainText`) and `parseGfmWithMath` (settled arm) call `mdast-util-from-markdown` with the same micromark extensions the replaced remark plugins wrapped, so block boundaries are identical everywhere. `mathCompatibility` (ex `remarkMathCompatibility`) now exports its micromark extension directly.
- **Incremental parsing** ([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)): CommonMark block parsing is line-based, so appended text reshapes only the parse frontier. `IncrementalMarkdownParser` keeps the trailing two blocks unstable (the last block is the frontier; the second-to-last is safety margin), freezes everything before them, and re-parses only the source tail from the last frozen block's `position.end.offset` — the parser's own offsets, no bespoke source scanning. Each source region parses O(1) times per stream instead of once per chunk; a single giant block (an unclosed fence) degrades to the old full-reparse cost and no worse. Non-append input resets the state under a bumped generation.
- **Rendering** ([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx), [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)): one switch over mdast node types replaces remark-rehype + react-markdown, reproducing the replaced pipeline's DOM byte-for-byte — table alignment as `text-align` styles, tight-list paragraph unwrapping, task-list classes and checkbox spacing, the footnote section (whose in-page anchors the protocol allowlist already reduced to plain text), literal raw HTML, the separator newlines that surface next to literal HTML text, and rehype-katex's three-arm error chain with KaTeX HTML mapped to React through the browser's own `DOMParser` (no wrapper element, so first/last-child margin rules still reach `.katex-display`; React 18 puts the `.katex-mathml` subtree in the HTML namespace exactly as the replaced pipeline did — a pre-existing limitation outside this parity contract, invisible to the visual `.katex-html` arm). Frozen blocks cache their React elements and keep source-offset keys, so crossing the freeze boundary reconciles instead of remounting; `MarkdownText` is memoized.
The DOM is pinned by `tests/fixtures/markdown-dom`: fixtures recorded from the react-markdown implementation before the swap, which the new renderer must reproduce under a whitespace-normalizing serializer. A fixture diff is a user-visible markdown style change to review, never to re-record for a refactor. `tests/markdown-incremental.spec.tsx` holds the equivalence property — at every appended prefix, chunked at 1/3/7/16 bytes, the live component's DOM equals a fresh mount's — plus freeze-boundary DOM-node identity and reset behavior.
This reverses the [assistant-markdown note](../feature/2026-07-23-web-assistant-markdown.md)'s rejected alternative ("maintain a custom React walker"): the incremental requirement is new evidence, the walker's security-sensitive branches (URL allowlist, image policy, inert HTML) were already product-owned functions, and the dependency no longer deleted owned code — it blocked the architecture. That note's untrusted-output policy and renderer selection are unchanged.
## Alternatives considered
**Keep react-markdown and split the source into per-segment `<ReactMarkdown>` instances.** Zero renderer ownership, but each frame parses the tail twice (boundary detection + render), settled math still re-parses everything, hast construction and the per-render processor remain, and blocks remount when crossing the freeze boundary because element trees cannot be cached across instances.
**Render cached mdast through `mdast-util-to-hast` + `hast-util-to-jsx-runtime`.** Keeps upstream's node mappings for free, but retains the hast intermediate per frame and two new direct dependencies for a pipeline whose mapping surface is small, closed, and now pinned by fixtures.
**Parse KaTeX output with `hast-util-from-html-isomorphic` (as rehype-katex does).** Pulls a parse5-based HTML parser into the bundle to parse trusted, vocabulary-constrained KaTeX output the browser's `DOMParser` (with the spec's SVG/MathML attribute adjustments) already parses identically.
## Consequences
Streaming per-chunk work now tracks the unstable tail instead of the whole reply, and react-markdown, remark-gfm, remark-math, rehype-katex, unified, and the hast chain left the browser bundle (`mdast-util-math` and `micromark-util-sanitize-uri` became direct dependencies; both were already transitive). The package owns ~25 node mappings, their tests, and the KaTeX DOM conversion — priced against the fixture contract that freezes their output. Two behavioral deviations, both healed by the settled full parse at finalize: a reference-style link or footnote whose definition lands on the other side of a freeze boundary renders literally while streaming, and a footnote reference can flash back to literal text when its definition freezes while the referencing block is still unstable. This module and KaTeX conversion assume a browser DOM (`DOMParser`), which the client-only package already did.
@@ -0,0 +1,33 @@
# Agent Note: 经由直接 mdast 渲染器的增量流式 Markdown
Status: implemented
[English](2026-08-06-web-markdown-incremental-ast-renderer.md) | 中文
## Problem
`MarkdownText` 在每次流式发布时都重新解析整个已累积的回复:react-markdown 的纯字符串 API 每次渲染都新建 unified processor,并对全文跑完 micromark → mdast → hast → React,因此每个 chunk 的主线程工作量随回复长度线性增长,整个流的累计成本随之二次增长。既有缓解手段(帧级合并、隔离的流式尾部、围栏 plain 臂)约束的是这份工作跑多频繁、波及多广,从未约束每次重新解析多少文本。修复它需要 AST 级输入——冻结已定型的块、只重新解析源文本尾部——这是纯字符串封装在结构上无法表达的。
## Decision
`MarkdownText` 直接渲染 mdast,并在流式期间增量解析:
- **语法**([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)):`parseGfm`(流式臂与 `extractMarkdownPlainText`)和 `parseGfmWithMath`(定稿臂)以被替换的 remark 插件所包装的同一组 micromark 扩展调用 `mdast-util-from-markdown`,因此各处块边界完全一致。`mathCompatibility`(原 `remarkMathCompatibility`)现在直接导出其 micromark 扩展。
- **增量解析**([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)):CommonMark 块解析按行推进,追加文本只会重塑解析前沿。`IncrementalMarkdownParser` 保留末尾两个块不稳定(最后一块是前沿;倒数第二块是安全裕量),冻结其前的所有块,只从最后一个冻结块的 `position.end.offset` 起重新解析源尾部——用的是解析器自己的偏移量,没有任何自制源扫描。每个源区间在整个流中解析 O(1) 次而非每 chunk 一次;单个巨型块(未闭合围栏)退化为旧的全量重解析成本,不会更差。非追加输入在递增的 generation 下重置状态。
- **渲染**([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx)、[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)):一个对 mdast 节点类型的 switch 取代 remark-rehype + react-markdown,逐字节复刻被替换管线的 DOM——表格对齐渲染为 `text-align` 样式、紧凑列表段落解包、任务列表类名与复选框空格、脚注区(其页内锚点本就被协议白名单降为纯文本)、字面 raw HTML、会与字面 HTML 文本相邻显形的分隔换行,以及 rehype-katex 的三臂容错链,KaTeX HTML 经浏览器自带的 `DOMParser` 映射为 React(无包裹元素,首/末子元素的 margin 规则仍能作用于 `.katex-display`;React 18 会把 `.katex-mathml` 子树放进 HTML 命名空间,与被替换管线完全一致——既有限制,不在本对等性契约范围内,对承担视觉渲染的 `.katex-html` 臂不可见)。冻结块缓存其 React 元素并保持源偏移 key,跨过冻结边界时走 reconcile 而非重挂载;`MarkdownText` 已 memo 化。
DOM 由 `tests/fixtures/markdown-dom` 钉死:fixture 录制自替换前的 react-markdown 实现,新渲染器必须在空白规整序列化器下复现。fixture 差异即用户可见的 markdown 样式变更,必须按此评审,绝不能为重构而重录。`tests/markdown-incremental.spec.tsx` 承载等价性性质——以 1/3/7/16 字节分块,在每个追加前缀处,常驻组件的 DOM 都等于全新挂载——外加冻结边界的 DOM 节点同一性与重置行为。
这推翻了[助手 Markdown Note](../feature/2026-07-23-web-assistant-markdown.md) 中被否决的备选("维护一个自定义 React walker"):增量需求是当时不存在的新证据,walker 的安全敏感分支(URL 白名单、图片策略、惰性 HTML)本就是产品自有函数,而该依赖不再删减自有代码——它阻塞了架构。该 Note 的不可信输出策略与渲染器选型不变。
## Alternatives considered
**保留 react-markdown,把源文本切成逐段 `<ReactMarkdown>` 实例。** 渲染器零自有成本,但每帧对尾部解析两次(边界检测 + 渲染),定稿数学仍要全量重解析,hast 构建与逐渲染 processor 依旧存在,且块跨过冻结边界时会重挂载——元素树无法跨实例缓存。
**用 `mdast-util-to-hast` + `hast-util-to-jsx-runtime` 渲染缓存的 mdast。** 白拿上游节点映射,但每帧保留 hast 中间层,并为一个映射面小、封闭、且已被 fixture 钉死的管线引入两个新直接依赖。
**用 `hast-util-from-html-isomorphic` 解析 KaTeX 输出(rehype-katex 的做法)。** 为解析可信、词汇受限的 KaTeX 输出把基于 parse5 的 HTML 解析器拉进 bundle,而浏览器自带的 `DOMParser`(带规范的 SVG/MathML 属性调整)解析结果完全相同。
## Consequences
流式的每 chunk 工作量现在跟随不稳定尾部而非整个回复,react-markdown、remark-gfm、remark-math、rehype-katex、unified 及 hast 链退出浏览器 bundle(`mdast-util-math``micromark-util-sanitize-uri` 成为直接依赖;两者原本就是传递依赖)。包自有约 25 个节点映射、其测试以及 KaTeX DOM 转换——代价由冻结其输出的 fixture 契约对冲。两个行为偏差,均在定稿的全量解析处自愈:定义落在冻结边界另一侧的引用式链接或脚注在流式期间渲染为字面文本;当脚注定义先冻结而引用块仍不稳定时,脚注引用可能闪回字面文本。本模块与 KaTeX 转换假定浏览器 DOM(`DOMParser`),这个 client-only 包本就如此。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md
2026-08-06-web-shell-dist-chunk-layout.md: 1c7b4273dc243685317b149e2fd7fddf2a6c18d1
2026-08-06-web-shell-dist-chunk-layout.zh.md: 6f4b94e0bd7412e480458e34922273b389aa8892
@@ -0,0 +1,50 @@
# Agent Note: Web shell dist chunk split and directory layout
Status: implemented
English | [中文](2026-08-06-web-shell-dist-chunk-layout.zh.md)
## Problem
The apps/web shell previously built into a single ~1.2 MB (minified) index chunk, roughly 80% of it vendor bytes — KaTeX, the boot grammars and the shiki engine, react-dom, the markdown pipeline — fused with all the workspace shell code (about one fifth). Any one-line shell change rehashed the whole chunk, forcing returning clients to redownload everything; `dist/assets/` was a flat single-level spread of 100-plus files (the main chunk, 23 lazy-loaded grammar chunks, 59 KaTeX font faces, and sourcemaps intermixed), impossible to navigate.
## Decision
`apps/web/vite.config.ts` splits the shell into two initial chunks via `manualChunks` and sorts the output into directories via naming functions; the entire configuration contains zero regexes — an exact-package-name Set, a filename list, an extension list.
**Membership** (`VENDOR_PACKAGES`, by exact npm package name):
- `vendor` = the three heavy rendering families: math (katex), highlight (shiki), markdown (the micromark/mdast parse pipeline — the incremental React renderer above it is workspace code and not part of this). The live membership is `VENDOR_PACKAGES`; the list is the packages workspace code **imports directly**: the remaining private transitive dependencies (the oniguruma family, @shikijs/core, character tables, dozens more) are referenced only by listed members, so rollup's chunk coloring pulls them into vendor automatically; dependencies shared with the index side fall back to index, diluting it by a few KB — not a correctness issue.
- **Every vendor member must be react-free (the boundary invariant)**: rollup folds a module shared between the entry and a manual chunk into the manual chunk — one listed package importing react/jsx-runtime would drag the single shared react copy into vendor, away from index. The React side of markdown/math rendering is workspace code and naturally lives in index, so the whole react family stays pinned to index.
- `index` (the default chunk) = the react family (react, react-dom, scheduler, use-sync-external-store), vendored cordis, all workspace code, and the unlisted small pieces (anser, clsx).
- `@shikijs/langs` is special-cased: the boot grammars (`BOOT_GRAMMAR_FILES`: typescript, shellscript, json — the three that highlight.ts statically imports, all self-contained data modules with zero internal imports) go into vendor; the remaining 23 lazy-loaded grammars get no assignment and each keeps its own on-demand chunk.
- `index.html` is wired up automatically by vite: index loads via `<script>` and vendor via `<link rel="modulepreload">`, so the two chunks fetch in parallel with no waterfall.
**Directory layout** (`chunkFileNames` + `assetFileNames`):
- The `assets/` root keeps only the index and vendor js (with their adjacent sourcemaps) and css.
- Grammar chunks go under `assets/langs/`. The criterion is whether a chunk's `moduleIds` include an `@shikijs/langs` member, not the facade: the shared chunks of embedded grammars (php/ruby/mdx embed html+javascript, which rollup splits out for sharing) **have no facade**, so a facade criterion would miss them; index and vendor are excluded by name, because vendor legitimately carries the three boot grammars.
- Fonts go under `assets/fonts/` (`FONT_EXTENSIONS`: woff2/woff/ttf; today all of them are KaTeX faces referenced by vendor.css — katex.min.css is imported by an index-side component, but CSS modules go through manualChunks like any module and follow `katex` into vendor.css; the browser fetches only woff2, on demand and only when a formula renders).
- Sourcemaps need no arrangement: rollup writes each `.map` next to its js and references it by bare relative filename, so when a chunk moves directories its map follows automatically.
All cross-directory references (index's dynamic imports into `langs/`, same-directory relative references among grammar chunks, vendor.css's relative references into `fonts/`) are emitted by the bundler, so the runtime needs zero accompanying changes; the host-side webserver serves the nested paths verbatim under its static prefix.
## Alternatives considered
- **Serving react and the other vendors from a CDN**: dsh web targets local/intranet hosts (often without internet access), so a CDN is simply unavailable; react is the platform seed external of every plugin bundle (the shell is its sole supplier), and switching to the CDN global-variable form would touch three places — the platform manifest, the seed, and the module table; the caching benefit is already delivered by the vendor split.
- **An inverse catch-all rule (everything in node_modules except the react family goes to vendor)**: membership cannot be read off the configuration, and small pieces like anser/clsx get misassigned to vendor; superseded by the positive exact-package-name list.
- **Regex family matching**: hard to read; exact package names plus rollup's automatic coloring of transitive dependencies make pattern matching unnecessary.
- **Identifying grammar chunks by facadeModuleId**: the facade-less shared chunks of embedded grammars would go undetected and fall back to the root directory; the `moduleIds` membership criterion covers both shapes.
- **Sheltering a react-edged rendering facade in vendor** (the historical react-markdown was one): rollup's shared-module folding would drag the single react copy into vendor, breaking the "react belongs to index" boundary; the constraint is codified as the list's boundary invariant.
- **Lazy-loading KaTeX wholesale, or turning the boot TypeScript grammar lazy**: either would change first-frame rendering behavior (the fallback for formulas / the first code block); that trade-off is independent of the dist layout and is decided separately.
## Verification
The audit tool ships with the repository: `node scripts/attribute-chunk-bytes.mjs <chunk.js>` (zero-dependency sourcemap VLQ byte attribution, aggregated by npm package / workspace directory). It verifies that vendor contains no workspace bytes, that the react family (including react/jsx-runtime) sits entirely in index, and that the npm side of index retains only the react family plus anser/clsx; the lazy grammar chunk count matches the `LAZY_GRAMMARS` table one to one; the browser keyless replay case is verbatim-identical to the pre-change baseline (apart from environment-specific local reds), so the two-chunk shell loads and renders with no regression.
## Consequences
- A shell code change rehashes only index (about one third of the dist output); vendor (about two thirds) stays cache-stable across shell releases and is invalidated only by dependency upgrades.
- `dist/assets/` is navigable: two js/css pairs at the root, on-demand grammars in `langs/`, fonts in `fonts/`.
- Maintenance cost: when workspace code adds a direct import of a rendering family's facade package, `VENDOR_PACKAGES` must be updated alongside (an omission merely dilutes index, nothing breaks); when the boot grammar set grows in highlight.ts without `BOOT_GRAMMAR_FILES` following, that grammar silently lands in index, visible only to a dist audit.
- The webserver's static surface has no compression yet, so the gzip size win is still on the table; transport-layer compression is a separate, independent decision.
@@ -0,0 +1,50 @@
# Agent Note: Web 壳产物的 chunk 切分与目录布局
Status: implemented
[English](2026-08-06-web-shell-dist-chunk-layout.md) | 中文
## Problem
apps/web 的壳此前打成单一约 1.2 MBminified)的 index chunk,其中约八成是 vendor 字节——KaTeX、boot 语法与 shiki 引擎、react-dom、markdown 管线——与全部 workspace 壳代码(约五分之一)熔在一起。任何一行壳代码改动都让整个 chunk 换哈希,回头客户端全量重新下载;`dist/assets/` 是 100 多个文件的单层平铺(主 chunk、23 个懒加载语法 chunk、59 个 KaTeX 字体面、sourcemap 混居),无从导航。
## Decision
`apps/web/vite.config.ts``manualChunks` 把壳切成两个初始 chunk,并以输出命名函数归类目录;整套配置零正则——精确包名 Set、文件名清单、扩展名清单。
**成员归属**`VENDOR_PACKAGES`,按精确 npm 包名):
- `vendor` = 三个重渲染家族:mathkatex)、highlightshiki)、markdownmicromark/mdast 解析管线——其上的增量 React 渲染器是 workspace 代码,不在此列)。成员以 `VENDOR_PACKAGES` 为活口径,清单 = workspace 代码**直接 import** 的包:其余私有传递依赖(oniguruma 系、@shikijs/core、字符表等数十个)只被清单成员引用,rollup 的 chunk 着色自动将其并入 vendor;与 index 侧共享的依赖回落 index,只稀释几 KB,不构成正确性问题。
- **vendor 全员必须 react-free(边界不变量)**rollup 会把入口与 manual chunk 共享的模块并入 manual chunk——清单里出现任何 import react/jsx-runtime 的包,唯一一份 react 副本就会被拽进 vendor、脱离 index。markdown/math 的 React 渲染侧是 workspace 代码天然住 indexreact 族因此全部钉在 index。
- `index`(默认 chunk= react 族(react、react-dom、scheduler、use-sync-external-store)、vendored cordis、全部 workspace 代码及未列入的小件(anser、clsx)。
- `@shikijs/langs` 特判:boot 语法(`BOOT_GRAMMAR_FILES`typescript、shellscript、json——highlight.ts 静态 import 的三件,均为零内部 import 的自含数据模块)进 vendor;其余 23 个懒加载语法不做指派,各自保持按需 chunk。
- `index.html` 由 vite 自动接线:index 走 `<script>`、vendor 走 `<link rel="modulepreload">`,两 chunk 并行拉取,无瀑布。
**目录布局**`chunkFileNames` + `assetFileNames`):
- `assets/` 根只留 index 与 vendor 的 js(含随行 sourcemap)与 css。
- 语法 chunk 归 `assets/langs/`。判据是 chunk 的 `moduleIds``@shikijs/langs` 成员,而非 facade:内嵌语法共享 chunkphp/ruby/mdx 内嵌 html+javascript,被 rollup 拆出共享)**没有 facade**facade 判据会漏;index/vendor 按名排除,因 vendor 合法携带 boot 三语法。
- 字体归 `assets/fonts/``FONT_EXTENSIONS`woff2/woff/ttf;今日全部为 vendor.css 引用的 KaTeX 字面——katex.min.css 虽由 index 侧组件 importcss 模块同样经 manualChunks 归属、随 `katex` 落入 vendor.css;浏览器按需只拉 woff2,且仅在公式渲染时)。
- sourcemap 无需安排:rollup 把 `.map` 写在各自 js 旁并以裸相对文件名引用,chunk 挪目录 map 自动跟随。
跨目录引用(index 的动态 import 指向 `langs/`、语法 chunk 间同目录相对引用、vendor.css 相对引用 `fonts/`)均由构建器生成,运行时零配套改动;host 侧 webserver 按静态前缀原样服务嵌套路径。
## Alternatives considered
- **react 等 vendor 走 CDN**dsh web 面向本机/内网主机(常无外网),CDN 直接不可用;react 是全部插件 bundle 的 platform seed external(壳是唯一供给方),改 CDN 全局变量形态需牵动 platform 清单/seed/模块表三处;缓存收益由 vendor 切分即可取得。
- **反向兜底规则(node_modules 除 react 族全归 vendor)**:成员从配置上读不出来,且把 anser/clsx 类小件错归 vendor;被正向精确包名清单取代。
- **正则家族匹配**:可读性差;精确包名 + rollup 对传递依赖的自动着色使模式匹配没有必要。
- **以 facadeModuleId 识别语法 chunk**:无 facade 的内嵌语法共享 chunk 会漏检落回根目录;`moduleIds` 成员判据覆盖两种形态。
- **在 vendor 里收留带 react 边的渲染门面**(历史上的 react-markdown 属此类):会经 rollup 的共享模块归并把唯一 react 副本拽进 vendor,破坏「react 归 index」的边界;该约束已成文为清单的边界不变量。
- **KaTeX 整体懒加载、boot TypeScript 语法转懒**:会改变首帧渲染行为(公式/首个代码块的回退),是独立于产物布局的取舍,另行决策。
## Verification
审计工具随库:`node scripts/attribute-chunk-bytes.mjs <chunk.js>`(零依赖 sourcemap VLQ 字节归属,按 npm 包/workspace 目录聚合)。以其复核:vendor 不含任何 workspace 字节、react 族(含 react/jsx-runtime)全量位于 index、index 的 npm 侧仅剩 react 族与 anser/clsx;懒语法 chunk 数量与 `LAZY_GRAMMARS` 表一一对应;浏览器 keyless replay 用例与改动前基线逐字一致(本机环境性红除外),两 chunk 壳装载渲染无回归。
## Consequences
- 壳代码改动只重哈希 index(约为产物三分之一);vendor(约三分之二)跨壳版本缓存稳定,仅依赖升级时失效。
- `dist/assets/` 可导航:根两对 js/css`langs/` 按需语法,`fonts/` 字体。
- 维护成本:workspace 代码新增对某渲染家族门面包的直接 import 时需同步 `VENDOR_PACKAGES`(漏列仅稀释 index,不致坏);在 highlight.ts 扩 boot 语法集而未同步 `BOOT_GRAMMAR_FILES` 时,该语法静默落入 index,仅产物审计可见。
- webserver 静态面尚无压缩,gzip 体量是潜在值;传输层压缩是另一项独立决策。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md # 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: 69d46894a53b0113f3e4f0fe871bbf3f9697969b 2026-07-29-sticky-composer-conversation-scroll.md: d3fed7a9d0b1f39f9551fbd85e0f83515b1a2690
2026-07-29-sticky-composer-conversation-scroll.zh.md: c0d5a0640468207282316ecd2fa1f209708df7b5 2026-07-29-sticky-composer-conversation-scroll.zh.md: 2beee34d3bb68832d14b7607b43aa11e1425d53d
@@ -10,7 +10,7 @@ The active conversation column split scrolling: the chat (and trajectory) view o
## Decision ## Decision
While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places a `data-composer-seat` around the whole `'conversation.composer'` chain output (fallback + elected overlay siblings from `overlay: true`). Active CSS sticks that seat with `position: sticky; bottom: 0` so Question/Approval takeovers stay visible when the user is not pinned to the floor; hero CSS centers the fallback stack inside the scroll body. `ConversationSession` keeps a chrome-hidden header + body shell while blank so that tree seat does not change on the first send. The session header remains `flex: none` column chrome above the scrollport when visible. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`. `ConversationRoot` always owns one `data-conversation-scroll` body, with the strict `conversation.session` view outlet before a `data-composer-seat` around the whole `'conversation.composer'` chain output (fallback + elected overlay siblings from `overlay: true`). The separate strict `conversation.session.header` outlet remains `flex: none` column chrome above that scrollport and hides while the Session is blank. This fixed parent tree keeps the scroll body and composer seat mounted from no session through the blank Hero and active conversation. Active CSS sticks that seat with `position: sticky; bottom: 0` so Question/Approval takeovers stay visible when the user is not pinned to the floor; Hero CSS centers the fallback stack inside the scroll body. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`.
Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host. Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host.
@@ -22,7 +22,7 @@ Chat history prepend follows reader intent through stable rendered node/call ide
**Fixed flex-none composer below the scrollport with wheel forwarding.** Rejected: the product requires the composer to stick inside the transcript scrollport so the footer is part of that scroll hit-testing surface, not a sibling that only forwards deltas. **Fixed flex-none composer below the scrollport with wheel forwarding.** Rejected: the product requires the composer to stick inside the transcript scrollport so the footer is part of that scroll hit-testing surface, not a sibling that only forwards deltas.
**Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; the wrap target is the Session body owned by the resident shell. **Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; its target is the root-owned scrollport in the resident shell.
**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned. **Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
@@ -30,4 +30,4 @@ Chat history prepend follows reader intent through stable rendered node/call ide
## Consequences ## Consequences
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft. Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. No session → blank Hero and Hero → active both keep the same textarea DOM node and InputHub draft.
@@ -10,7 +10,7 @@ Status: implemented
## Decision ## Decision
只要存在会话,`ConversationRoot` 就会始终提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并用 `data-composer-seat` 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 QuestionApproval 接管仍可见;hero CSS 在滚动主体内居中 fallback 栈。`ConversationSession` 在 blank 时保留隐藏 chrome 的 header + body 壳,使首次发送时树座位不变。可见时会话标题栏仍是滚动容器之上的 `flex: none` 列 chrome。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 从无 session、blank Hero 到活跃对话始终保持挂载。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 QuestionApproval 接管仍可见;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` 施加到宿主。 会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。
@@ -22,7 +22,7 @@ Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图
**滚动容器下方 flex-none 固定编辑器并转发滚轮。** 否决:产品要求编辑器 sticky 在 transcript 滚动容器内,使页脚成为该滚动命中面的一部分,而不是仅转发增量的兄弟节点。 **滚动容器下方 flex-none 固定编辑器并转发滚轮。** 否决:产品要求编辑器 sticky 在 transcript 滚动容器内,使页脚成为该滚动命中面的一部分,而不是仅转发增量的兄弟节点。
**把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;包装目标是常驻壳拥有的 Session 主体 **把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;目标是常驻壳中由 root 持有的滚动容器
**把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。 **把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。
@@ -30,4 +30,4 @@ Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图
## Consequences ## Consequences
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。hero → active 保持同一 textarea DOM 节点assembled slash-flow 快照)以及 InputHub 草稿。 在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。无 session → blank Hero 与 Hero → active 保持同一 textarea DOM 节点以及 InputHub 草稿。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md
2026-07-31-hero-visible-while-blank-session-opens.md: b39963beffa403ef6fa44735aa88395a99139751 2026-07-31-hero-visible-while-blank-session-opens.md: 6afa5d0ee2b695d6805d20f54e82073db8028df7
2026-07-31-hero-visible-while-blank-session-opens.zh.md: b451d7c00ec7eb8d736134e738d72e5e07fd1b04 2026-07-31-hero-visible-while-blank-session-opens.zh.md: f21e549b5811d81094374b1363186a1d18fbadaf
@@ -24,12 +24,10 @@ The summary flag and the snapshot's own `blank` are distinct sources: the snapsh
## Deferred ## Deferred
The no-session→session tree relocation in `ConversationRoot` (the hero/composer subtree moves into the `conversation.session` outlet) still rebuilds the composer DOM on the same transition; removing it means moving `conversation.session` to `session-maybe` scope, a slot-contract change that needs its own proposal.
Object-layer reference churn found while diagnosing this — no-op projections minting fresh snapshots, the create path projecting twice, `select()` using `notifyNow` from async continuations — is real but independent of the visible flash. Object-layer reference churn found while diagnosing this — no-op projections minting fresh snapshots, the create path projecting twice, `select()` using `notifyNow` from async continuations — is real but independent of the visible flash.
## Consequences ## Consequences
Startup auto-selection renders the hero immediately and keeps the composer seat and header visible through the history round-trip, so launching into a recent workspace no longer looks like a page reload. Sessions whose summary does not prove them blank keep the previous settling behavior, so the guard still covers the case it was written for. Skeleton tests pin all three summary shapes: a row reporting `blank: false` settles, an absent row settles, and a summary-proven blank session opening under `loading` renders hero chrome with a live textarea. Startup auto-selection renders the hero immediately and keeps the composer seat and header visible through the history round-trip, so launching into a recent workspace no longer looks like a page reload. Sessions whose summary does not prove them blank keep the previous settling behavior, so the guard still covers the case it was written for. Skeleton tests pin all three summary shapes: a row reporting `blank: false` settles, an absent row settles, and a summary-proven blank session opening under `loading` renders hero chrome with a live textarea.
The assembled coverage is `apps/web/tests/startup-auto-selection.e2e.ts` (keyless web browser lane): it registers a workspace, holds the `session.history` response open at the browser's network boundary, and asserts the visible frame while the auto-selected open is in flight — hero phase, hero title, painted composer — plus a recorded phase timeline of exactly `['hero']` for the whole load. Holding the round-trip is what makes it a regression test rather than a race: against a loopback host the open settles too fast to sample, and with the exemption reverted the held window is precisely when the root reports `settling`. The assembled coverage is `apps/web/tests/startup-auto-selection.e2e.ts` (keyless web browser lane). Its first Workspace connection asserts that the Hero root, Workspace chip, scroll body, composer seat, and textarea remain the same DOM nodes when the blank Session appears. It then holds the `session.history` response open at the browser's network boundary and asserts the visible frame while the auto-selected open is in flight — hero phase, hero title, painted composer — plus a recorded phase timeline of exactly `['hero']` for the whole load. Holding the round-trip is what makes the second case a regression test rather than a race: against a loopback host the open settles too fast to sample, and with the exemption reverted the held window is precisely when the root reports `settling`.
@@ -24,12 +24,10 @@ Status: implemented
## 推迟事项 ## 推迟事项
`ConversationRoot` 中"无会话→有会话"的树位置迁移(hero/composer 子树移入 `conversation.session` 出口)仍会在同一次转换中重建 composer 的 DOM;消除它意味着把 `conversation.session` 移到 `session-maybe` 作用域,这是一次插槽契约变更,需要单独立项。
诊断期间发现的对象层引用抖动——空操作投影铸造出新的快照、创建路径重复投影一次、`select()` 在异步续体中使用 `notifyNow`——确实存在,但与这次可见闪烁相互独立。 诊断期间发现的对象层引用抖动——空操作投影铸造出新的快照、创建路径重复投影一次、`select()` 在异步续体中使用 `notifyNow`——确实存在,但与这次可见闪烁相互独立。
## 影响 ## 影响
启动自动选择会立即渲染 hero,并在整个历史往返期间保持 composer 座位与 header 可见,因此启动进入最近工作区不再像页面重载。摘要未证明为空白的会话保持原有的 settling 行为,这道防护仍覆盖它当初针对的场景。骨架测试固定了摘要的三种形态:报告 `blank: false` 的行进入 settling;根本没有该行同样进入 settling;摘要已证明为空白的会话在 `loading` 期间渲染 hero 外壳与可用的文本框。 启动自动选择会立即渲染 hero,并在整个历史往返期间保持 composer 座位与 header 可见,因此启动进入最近工作区不再像页面重载。摘要未证明为空白的会话保持原有的 settling 行为,这道防护仍覆盖它当初针对的场景。骨架测试固定了摘要的三种形态:报告 `blank: false` 的行进入 settling;根本没有该行同样进入 settling;摘要已证明为空白的会话在 `loading` 期间渲染 hero 外壳与可用的文本框。
组装级覆盖是 `apps/web/tests/startup-auto-selection.e2e.ts`(无密钥的 Web 浏览器泳道):它注册一个工作区,在浏览器网络边界上扣住 `session.history` 的响应,并在自动选择的打开仍在飞行途中断言可见画面——hero 阶段、hero 标题、已绘制的 composer——外加整次加载记录到的阶段时间线恰好为 `['hero']`。扣住这次往返正是成为回归测试而非竞态的原因:对着回环主机,打开会快到无从采样;而一旦回退这条豁免,被扣住的这段窗口恰恰就是根节点报告 `settling` 的时刻。 组装级覆盖是 `apps/web/tests/startup-auto-selection.e2e.ts`(无密钥的 Web 浏览器泳道)。首次连接 Workspace 时,它断言 blank Session 出现前后 Hero root、Workspace chip、滚动主体、composer seat 与 textarea 都是同一 DOM 节点。随后它在浏览器网络边界上扣住 `session.history` 的响应,并在自动选择的打开仍在飞行途中断言可见画面——hero 阶段、hero 标题、已绘制的 composer——外加整次加载记录到的阶段时间线恰好为 `['hero']`。扣住这次往返正是第二个用例成为回归测试而非竞态的原因:对着回环主机,打开会快到无从采样;而一旦回退这条豁免,被扣住的这段窗口恰恰就是根节点报告 `settling` 的时刻。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md
2026-08-05-context-meter-blind-to-compaction.md: ab39ae4e109f238960fd60de5e5b61075344f525
2026-08-05-context-meter-blind-to-compaction.zh.md: c93aa509530e48acf906b18ba85bab4c7d355d69
@@ -0,0 +1,46 @@
# Agent Note: the context meter could not see a compaction
Status: implemented
English | [中文](2026-08-05-context-meter-blind-to-compaction.zh.md)
## Problem
The composer's [context meter](../feature/2026-08-05-composer-context-meter-breakdown.md) took its ring, percentage, and `~used / capacity` header from `contextPressure.pressureTokens`, the newest provider-reported prompt size. That number moves only when a request reports usage, and compaction reports none: `compact-basic` summarizes through a direct `ctx.llm.stream()` call and appends `compact/start`, `compact/summary`, the replacement `user/message`, and `compact/end` — no `assistant/message`, no usage chunk.
So the meter was frozen across the one action taken to change it. Driving a real `compactNow` through the agent loop:
```
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]
```
The composition rows, which fold the surface, dropped by 93%. The ring — the primary affordance, and the reason a user opens the panel right after compacting — did not move at all, and would not until an entire further turn completed. The panel then showed a header and rows disagreeing by more than an order of magnitude, at exactly the moment a reader was most likely to add the rows up.
## Decision
`contextPressure` publishes a second numerator, `projectedTokens`: the provider sample plus the heuristic repricing of everything the surface gained or lost since that sample was taken, clamped at zero. The fold carries the priced surface through the shared `surface-fold.ts` and stamps `sampledSurfaceTokens` when a usage sample lands — **before** the same event joins the surface, so an `assistant/message` anchors against the surface its own request actually carried. `stateVersion` moves to 3.
Only the delta is estimated. The anchor stays provider-exact, which keeps the estimator's systematic CJK and JSON-schema underpricing out of the occupancy figure while still letting the number react the moment content lands or a span is shadowed. `contextOccupancy` reads `projectedTokens` and falls back to the bare sample, so a projection restored from a pre-field checkpoint degrades to the old behavior instead of vanishing.
This reverses the "the ring, header, and bar length stay provider-exact" half of the [context meter decision](../feature/2026-08-05-composer-context-meter-breakdown.md). What that decision was protecting — not fabricating precision by scaling heuristic rows to a provider total — is preserved: the rows are still unscaled, and the header still does not equal their sum. What changed is the recognition that "provider-exact but describing a request two compactions ago" is not the more truthful figure.
## 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.
**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.
**Let the UI subtract, exposing `sampledSurfaceTokens` and reading `contextBreakdown.messageTokens`.** Splits one figure's arithmetic across two projections and the client. The host owns the vocabulary; it should publish the whole value.
## Consequences
Occupancy now advances with every surface event rather than once per turn, so the ring creeps up as a turn produces tool results instead of jumping at its end — and drops the instant a compaction lands. That is more projection frames on the wire: one per surface event for `contextPressure`, the rate `contextBreakdown` already ran at.
The panel's composition rows still do not sum to the header, and now for one clearly-stated reason instead of two: the rows carry the estimator's error, the header's anchor does not. The remaining lever is estimator accuracy (CJK-aware weighting in `estimate.ts`), which changes no seam.
`sampledSurfaceTokens` assumes nothing joins the surface between a step's request and its usage report. The loop admits steering and context before `buildRequest` and drains tool results after `assistant/message`, so that holds; if it ever stops holding, the error is bounded by one message and self-corrects at the next sample.
## Testing
`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.spec.tsx` pins the ring reading the projected figure, and `chat-stats-bash-sample.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted.
@@ -0,0 +1,46 @@
# Agent Note:上下文仪表看不见压缩
Status: implemented
[English](2026-08-05-context-meter-blind-to-compaction.md) | 中文
## 问题
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`,也没有用量分片。
于是在唯一一个专门用来改变它的操作面前,这块仪表纹丝不动。通过真实 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%。而圆环——那个主要的可操作元素,也正是用户压缩完立刻会去点开面板的理由——完全没动,而且要等到又跑完一整轮才会动。此时面板上的标题与明细行相差一个数量级以上,恰恰发生在读者最可能去把明细行加总的时刻。
## 决策
`contextPressure` 发布第二个分子 `projectedTokens`:在提供方样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零。该折叠通过共享的 `surface-fold.ts` 携带已计价的表层,并在用量样本落地时记下 `sampledSurfaceTokens`——记录时机在同一条事件加入表层**之前**,因此 `assistant/message` 锚定的正是它自己那次请求实际携带的表层。`stateVersion` 提升到 3。
只有增量部分是估算的。锚点保持提供方精确值,从而把估算器对 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)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。
**在压缩结束时补写一条合成的用量记录。** 这确实能推动 `pressureTokens` 本身,但压缩手上唯一的用量是摘要请求自己的用量——那是完全另一个提示词。把它记成本对话的提示词规模,等于把谎言写进持久日志,而不只是写进某一处展示。
**让 UI 自己做减法:暴露 `sampledSurfaceTokens`,再读 `contextBreakdown.messageTokens`。** 这会把一个数字的算术拆散到两个投影和客户端三处。词汇的所有者是宿主,就应当由它发布完整值。
## 影响
占用率现在随每个表层事件推进,而不再是每轮跳一次,因此一轮中产生工具结果时圆环会持续爬升,而不是等到轮次结束才跳变——压缩落地的瞬间它也会掉下来。代价是线路上多了投影帧:`contextPressure` 每个表层事件推一帧,也就是 `contextBreakdown` 本来就在跑的频率。
面板的组成明细行仍然加不出标题数字,但现在只剩一个能讲清楚的原因,而不是两个:明细行带着估算器的误差,标题的锚点不带。剩下的抓手是估算精度(在 `estimate.ts` 里做 CJK 感知加权),它不改动任何 seam。
`sampledSurfaceTokens` 依赖一个前提:在某个步骤的请求与它的用量报告之间,不会有新内容加入表层。循环在 `buildRequest` 之前接纳 steering 与 context,在 `assistant/message` 之后才排空工具结果,因此该前提成立;即便将来不再成立,误差也被限制在一条消息以内,并在下一个样本处自行纠正。
## 测试
`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了样本在表层增长与一次压缩上的推进(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats-bash-sample.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md
2026-08-06-token-surface-unpriced-replace-compatibility.md: 77b778fba695f560eb68af6da2f416e61ccff181
2026-08-06-token-surface-unpriced-replace-compatibility.zh.md: dd4c5883c4c6d37809c2f8100f266655d8ba600c
@@ -0,0 +1,35 @@
# Agent Note: unpriced surface replacements fold neutrally
Status: implemented
English | [中文](2026-08-06-token-surface-unpriced-replace-compatibility.zh.md)
## Problem
The `contextPressure` and `contextBreakdown` projections keep a running surface-token total plus at most one pending shadow-price claim, so their persisted checkpoints stay O(1) over a session's life. Current replace producers append a `compact/summary` or `compact/prune` metering event immediately before the replacement; its `shadowedTokenCount` prices the exact replaced range, and `foldSurfaceProjection` turns that into the signed delta.
Sessions recorded before the shadow-price protocol log replacements with no adjacent metering event. The O(1) state cannot reconstruct the replaced range's price, and the fold treated every unpriced replacement as a contract violation and threw — so replaying such a session died at its first replacement (`token surface: replace at seq … has no adjacent shadow price`), leaving the session permanently unopenable.
## Decision
A replace that arrives with no armed claim folds price-neutrally: `foldSurfaceProjection` returns `deltaTokens: 0`, pricing the replaced range as if it had cost exactly what its replacement costs, and replay continues. A claim expired by an intervening event reaches the same neutral path, since the fold cannot distinguish it from a log that never metered.
An armed claim naming a **different** range still throws. There the metering event was adjacent, so the producer wrote contradictory adjacent events — a live shadow-price contract violation, not historical data, and it must fail loud rather than let the total drift silently.
Both projections share the one fold, so neither gains state fields nor bumps its `stateVersion`. `surface-fold.ts` and `ctx.tokenMeter.measure()` are unaffected: they hold the per-node priced surface and never needed the claim protocol.
## Alternatives considered
**Keep throwing.** Preserves the strict producer contract, but every pre-protocol session stays permanently unreplayable, and the projections exist to serve replay.
**Persist the full priced surface in the projection state.** Could price any replaced range exactly, but grows the checkpoint by one node per model-visible message without bound — defeating the O(1) constraint the shadow-price protocol exists to preserve (see [the context-meter note](2026-08-05-context-meter-blind-to-compaction.md)).
## Consequences
An unpriced replacement holds the total still instead of shrinking it, so the compacted-away span stays counted: `contextBreakdown.messageTokens` retains the overcount, and `contextPressure.projectedTokens` overestimates occupancy only until the next usage sample re-anchors it, because that figure tracks movement since the sample rather than the absolute level. The error direction is safe — overestimating occupancy at worst invites an earlier compaction.
The loud failure survives where it still means something: a range-mismatched adjacent claim is a current producer bug and still throws.
## Testing
`packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` pins the neutral fold for the no-claim and expired-claim replacements, the throw for a mismatched claim, and the exact pricing for a matched one. `packages/llm/token-meter/tests/token-usage-projection.spec.ts` pins `contextPressure` holding still across an unpriced replacement.
@@ -0,0 +1,35 @@
# Agent Note: 未计价的表层替换以中性方式折叠
Status: implemented
[English](2026-08-06-token-surface-unpriced-replace-compatibility.md) | 中文
## 问题
`contextPressure``contextBreakdown` 两个投影只维护一份滚动累计的表层 token 总量,外加至多一条待结算的影子价格(shadow price)声明,因此其持久化检查点在会话整个生命周期内保持 O(1)。当前的替换生产方会紧贴在替换之前追加一条 `compact/summary``compact/prune` 计量事件;其 `shadowedTokenCount` 对被替换区间精确计价,`foldSurfaceProjection` 再把它换算成有符号增量。
影子价格协议引入之前录制的会话,其日志中的替换没有相邻的计量事件。O(1) 状态无法重建被替换区间的价格,而折叠此前把每一次未计价替换都当作契约违规并抛出异常,于是回放这类会话会在第一处替换就中断(`token surface: replace at seq … has no adjacent shadow price`),会话从此永远无法打开。
## 决策
到达时没有已就位声明的替换以价格中性的方式折叠:`foldSurfaceProjection` 返回 `deltaTokens: 0`,相当于把被替换区间计价为恰好等于其替换内容的成本,回放随即继续。因中间插入的事件而过期的声明也走同一条中性路径,因为折叠无法把它与从未计量过的日志区分开。
已就位但指向**另一个**区间的声明仍会抛出异常。此时计量事件确实相邻,说明生产方写入了互相矛盾的相邻事件:这是现行影子价格契约的违规,不是历史数据,必须响亮失败,而不能任由总量悄然漂移。
两个投影共用同一个折叠,因此二者都不新增状态字段,也不提升 `stateVersion``surface-fold.ts``ctx.tokenMeter.measure()` 不受影响:它们持有逐节点的已计价表层,本来就不需要声明协议。
## 备选方案
**维持抛出异常。**保住了严格的生产方契约,但协议之前的每个会话都将永远无法回放,而投影本就是为服务回放而存在的。
**在投影状态中持久化完整的已计价表层。**可以对任意被替换区间精确计价,但检查点会随每条模型可见消息各增加一个节点、无上限地增长,恰恰破坏了影子价格协议所要守住的 O(1) 约束(见[上下文仪表的 Agent Note](2026-08-05-context-meter-blind-to-compaction.md))。
## 影响
未计价的替换让总量保持不动而不是缩小,因此被压缩(compaction)掉的区段仍被计入:`contextBreakdown.messageTokens` 保留这部分多计的量;`contextPressure.projectedTokens` 会高估占用率,但只持续到下一个用量样本重新锚定为止,因为该数字追踪的是自样本以来的增减,而非绝对水平。误差方向是安全的:高估占用率最坏不过是招致一次更早的压缩。
响亮失败保留在它仍有意义的地方:区间不匹配的相邻声明是现行生产方的缺陷,仍会抛出异常。
## 测试
`packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` 钉住了无声明与声明过期两种替换的中性折叠、声明区间不匹配时的抛出异常,以及声明匹配时的精确计价。`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 钉住了 `contextPressure` 在一次未计价替换前后保持不动。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
2026-06-18-compaction-capability-seam.md: 27dbde9f2349681cf47c4d25b16399b26ed9e1ca 2026-06-18-compaction-capability-seam.md: 26e6e2468c7bea661d85c8fb994adf8b109105ee
2026-06-18-compaction-capability-seam.zh.md: 1fe9ece2861bd6d75633a866a4a11eaadbf7ef26 2026-06-18-compaction-capability-seam.zh.md: 8f9cd1f6bc31e648a5b923e816cad75ebc1a0bd8
@@ -119,7 +119,7 @@ The lifecycle boundary makes crash state unambiguous:
## Consequences ## Consequences
- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, `compact-tool-result-prune` supplies optional deterministic rewriting, and `command-compact` supplies human `/compact`. `packages/llm/token-meter` owns replay-aware measurement independently. - **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, `compact-tool-result-prune` supplies optional deterministic rewriting, and `command-compact` supplies human `/compact`. `packages/llm/token-meter` owns replay-aware measurement independently.
- **Automatic seams**: `agent/pre-step` (`@mode waterfall`) handles pressure before request derivation and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Pre-step receives the claimed batch and `PreStepContext`, with no compaction-only prompt/prefix payload. - **Automatic seams**: `agent/pre-step` (`@mode waterfall`) handles pressure before request derivation and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. The pre-step payload carries the claimed batch, turn, step, and signal (see the [payload-object events decision](../architecture/2026-08-06-agent-event-payload-objects.md)), with no compaction-only prompt/prefix payload.
- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry.
- **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent `compactRegion` and `compactIfNeeded` from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results. - **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent `compactRegion` and `compactIfNeeded` from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results.
- **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call, while the compaction companion owns numeric-turn versus standalone-null bracket relations. - **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call, while the compaction companion owns numeric-turn versus standalone-null bracket relations.
@@ -119,7 +119,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab
## 后果 ## 后果
- **包**`packages/compact/compact` 提供接口,`compact-basic` 提供后端,`compact-tool-result-prune` 提供可选的确定性重写,`command-compact` 提供面向用户的 `/compact``packages/llm/token-meter` 独立拥有回放感知的测量。 - **包**`packages/compact/compact` 提供接口,`compact-basic` 提供后端,`compact-tool-result-prune` 提供可选的确定性重写,`command-compact` 提供面向用户的 `/compact``packages/llm/token-meter` 独立拥有回放感知的测量。
- **自动 seam**`agent/pre-step``@mode waterfall`)在请求派生前处理压力,`agent/request-error``@mode waterfall`)处理失败步骤关闭后的最终请求失败。pre-step 接收已领取批次与 `PreStepContext`,不携带压缩专属的提示词/前缀 payload。 - **自动 seam**`agent/pre-step``@mode waterfall`)在请求派生前处理压力,`agent/request-error``@mode waterfall`)处理失败步骤关闭后的最终请求失败。pre-step 的 payload 携带已领取批次、轮次、步骤与 signal(参见 [payload-object 事件决策](../architecture/2026-08-06-agent-event-payload-objects.md),不携带压缩专属的提示词/前缀 payload。
- **`SessionEventMap`** 通过可合并扩展的声明合并获得 `compact/start` / `compact/summary` / `compact/end``SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 - **`SessionEventMap`** 通过可合并扩展的声明合并获得 `compact/start` / `compact/summary` / `compact/end``SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。
- **`dsh-compact`** 拥有 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource(source)``toolPairingBalancedBefore(session, seq)``toolPairingBalancedAfter(session, seq)`。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 `compactRegion``compactIfNeeded` 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。 - **`dsh-compact`** 拥有 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource(source)``toolPairingBalancedBefore(session, seq)``toolPairingBalancedAfter(session, seq)`。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 `compactRegion``compactIfNeeded` 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。
- **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用,而压缩配套组件拥有数字轮次归属与独立 `null` 归属标记对之间的关系。 - **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用,而压缩配套组件拥有数字轮次归属与独立 `null` 归属标记对之间的关系。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-interception-seams.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-interception-seams.md
2026-06-30-interception-seams.md: 629a1aed509bd9bce9a2da89ce84b17a1db8e6b6 2026-06-30-interception-seams.md: c318e41cfb1d64230b6151f1febad85d75b1451d
2026-06-30-interception-seams.zh.md: d6958c9d1e7a8af8fa06d859d1905719a19cd43d 2026-06-30-interception-seams.zh.md: 1b274fae4bc7fde326dbb0eeec54d57f73987803
@@ -15,8 +15,8 @@ The surface needs distinct contracts for per-prompt policy (CC's `UserPromptSubm
The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small seam-specific **typed Decision unions**; wrappers return normalized results; notifications receive immutable snapshots and cannot affect the outcome. The set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation) while leaving non-hook execution policy independently composable. The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small seam-specific **typed Decision unions**; wrappers return normalized results; notifications receive immutable snapshots and cannot affect the outcome. The set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation) while leaving non-hook execution policy independently composable.
**Agent events** (`dsh-agent`): **Agent events** (`dsh-agent`):
- `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. - `agent/session-start({ agent, source })` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`.
- `agent/pre-step(agent, messages, context, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. `PreStepContext` carries that request's `turn`, `step`, and cancellation `signal`; `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed. - `agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. The payload carries the request's `turn`, `step`, and cancellation `signal` (the retired `PreStepContext` fields live in the payload; see the [payload-object events decision](../architecture/2026-08-06-agent-event-payload-objects.md)); `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed.
**`agent/turn-stopping`** is an awaited notification at the natural stop boundary. A listener that needs another step calls `agent.steer()` with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn. **`agent/turn-stopping`** is an awaited notification at the natural stop boundary. A listener that needs another step calls `agent.steer()` with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn.
@@ -15,8 +15,8 @@ harness 需要一套钩子子系统:用户像 Claude CodeCC)和 Codex 那
规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start``prompt-submit``pre-tool``post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留作独立可组合。 规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start``prompt-submit``pre-tool``post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留作独立可组合。
**Agent 事件**`dsh-agent`): **Agent 事件**`dsh-agent`):
- `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource``startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 - `agent/session-start({ agent, source })` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource``startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。
- `agent/pre-step(agent, messages, context, next) → PreStepDecision` ——waterfall,在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。`PreStepContext` 携带该请求的 `turn``step` 与取消 `signal`;没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。 - `agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision` ——waterfall,在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。payload 携带该请求的 `turn``step` 与取消 `signal`(已退役的 `PreStepContext` 字段位于 payload 中;参见 [payload-object 事件决策](../architecture/2026-08-06-agent-event-payload-objects.md);没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。
**`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的 steering(中途引导)内容供模型使用;循环随后重新读取 outbox,继续执行或关闭轮次。 **`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的 steering(中途引导)内容供模型使用;循环随后重新读取 outbox,继续执行或关闭轮次。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
2026-07-23-web-assistant-markdown.md: 8a8778351911bcb3448366c718aa124c4a89de58 2026-07-23-web-assistant-markdown.md: ad86559e3b5294b6bd67d69ff5c6ce37a5172008
2026-07-23-web-assistant-markdown.zh.md: 2ac024e24ff95b4eb296112562c93f343b832187 2026-07-23-web-assistant-markdown.zh.md: b47375db843ea1c81b913e3f7ac8cd1bbc278830
@@ -12,9 +12,9 @@ The Web conversation preserves assistant Markdown source through session events,
`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. `@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. `MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk.
Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). When one inline-code token consists entirely of an absolute HTTP(S) URL, its code chrome contains the same keyboard-focusable safe external anchor as an ordinary link; port, path, and query text remain unchanged, while commands, partial URLs, other schemes, and fenced code stay inert. `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through the settled grammar's math extensions; `mathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes.
The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle.
@@ -26,7 +26,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
## Alternatives considered ## Alternatives considered
**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. **Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.*
**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored surfaces remain literal until the product chooses that behavior explicitly. **Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored surfaces remain literal until the product chooses that behavior explicitly.
@@ -36,6 +36,10 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
**Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. **Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract.
**Preprocess Markdown source or repair text nodes after parsing for CJK punctuation boundaries.** A source rewrite must reproduce escape, code, math, and delimiter rules before the parser owns those distinctions, while a text-node repair has already lost some source intent and cannot compose with parsed inline nodes. Extending attention at the tokenizer boundary preserves the upstream resolver and limits the divergence to delimiter eligibility.
**Require the model to emit standard links and leave URL-shaped inline code inert.** Output guidance cannot make persisted or third-party model replies uniform, and inline code is a common way to distinguish a literal endpoint. Recognizing only a complete absolute HTTP(S) value at the rendered inline-code boundary preserves code semantics while applying the existing untrusted-link policy.
## Consequences ## Consequences
Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred. Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses only the unstable tail after each accumulated update; incomplete Markdown can temporarily change the tail's structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. URL-shaped inline code becomes navigable without changing its visible literal, while unsafe schemes and mixed code remain non-interactive. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred.
@@ -12,9 +12,9 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 `@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
`MarkdownText` 使用 `react-markdown``remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 `MarkdownText` `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有该机制及其 DOM 一致性契约)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。
视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md``markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*``--dsw-font-markdown-*``--dsw-alias-border-l*``--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math``rehype-katex` 渲染 KaTeX`remarkMathCompatibility``\(...\)``\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md``markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*``--dsw-font-markdown-*``--dsw-alias-border-l*``--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。当单个行内代码 token 完全由绝对 HTTP(S) URL 构成时,其代码外框会包含一个与普通链接相同、可通过键盘聚焦的安全外链锚点;端口、路径与查询文本保持不变,而命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过定稿语法的数学扩展渲染 KaTeX`mathCompatibility``\(...\)``\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。
该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。 该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。
@@ -26,7 +26,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
## 考虑过的替代方案 ## 考虑过的替代方案
**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。 **将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有。*
**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这两类输入内容仍按字面渲染。 **将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这两类输入内容仍按字面渲染。
@@ -36,6 +36,10 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
**移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 **移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。
**为处理 CJK 标点边界而预处理 Markdown 源文本,或在解析后修复文本节点。**源文本重写必须在解析器掌握这些区别之前复现转义、代码、数学公式与定界符规则;文本节点修复则已经丢失部分源文本意图,也无法与已解析的行内节点组合。在分词器边界扩展 attention 可保留上游 resolver,并将差异限制在定界符的适用条件上。
**要求模型输出标准链接,并让 URL 形态的行内代码保持不可交互。**输出指引无法统一已持久化回复与第三方模型回复,而行内代码是将端点标记为字面值的常见方式。仅在行内代码的渲染边界识别完整的绝对 HTTP(S) 值,可在应用现有不受信任链接策略的同时保留代码语义。
## 后果 ## 后果
assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。 assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出重新解析不稳定的尾部;未完成的 Markdown 可能暂时改变尾部结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。URL 形态的行内代码会在不改变其可见字面文本的情况下变得可导航,而采用不安全 scheme 或混有其他内容的代码仍不可交互。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md
2026-07-23-web-todo-display.md: 7223ff9adbf1fa6dca39c9eb4949b6d3861bdd6d 2026-07-23-web-todo-display.md: d5939c0fa6ab83a61f1932622c4b6a150ccaded7
2026-07-23-web-todo-display.zh.md: 98390c8c2cd95be9d5565b4062d00c1d99215cea 2026-07-23-web-todo-display.zh.md: b1ecf679efe81016e00c5c1221c04730b3ce98bb
@@ -18,11 +18,11 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it
### TodoPanel: the durable list as a persistent strip ### TodoPanel: the durable list as a persistent strip
The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"<done>/<total> tasks · <n> in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, using `ctx.slots.inject` with no `ConversationService` edge, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"<done>/<total> tasks · <n> in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper.
### TodoRow: the per-call row through the keyed toolview slot ### TodoRow: the per-call row through the keyed toolview slot
The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot via `ctx.slots.register` — the same seam and load-order posture as the bash sample (`inject: ['slots', 'conversation']`), but a product registration. The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot through `ctx.slots.inject`, the same declaration-lifetime posture as the bash sample but a product registration. The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card.
## Alternatives considered ## Alternatives considered
@@ -18,11 +18,11 @@ Status: implemented
### TodoPanel:持久化列表作为一条常驻横条 ### TodoPanel:持久化列表作为一条常驻横条
面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,采用与 QueueDock 相同的模式:以 `inject: ['slots', 'conversation']` 作为加载顺序 seam`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(折叠态不显示进行中条目的内容提示)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)``max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配只是一行包装。 面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry` 使用 `ctx.slots.inject`,不依赖 `ConversationService``order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)``max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配只是一行包装。
### TodoRow:经 keyed toolview slot 的逐调用行 ### TodoRow:经 keyed toolview slot 的逐调用行
专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.register` 注册进 keyed 的 `conversation.chat.toolview` slot——与 bash 样例使用同一 seam、相同的加载顺序模式(`inject: ['slots', 'conversation']`,但属产品级注册。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,持久化列表从会话事件渲染,而非工具卡。 专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.inject` 注册进 keyed 的 `conversation.chat.toolview` slot,遵循与 bash 样例相同的声明生命周期,但属产品级注册。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。
## 考虑过的替代方案 ## 考虑过的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
2026-07-30-deepseek-onboarding-credential-setup.md: ed53ffe64d3ba27e8746d58ad84d1a4c401f6ce4 2026-07-30-deepseek-onboarding-credential-setup.md: c732758bc567376a0be4ac348aa9129aa8126ac4
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 340728ab9348e0132954e403f9bdbf561e340247 2026-07-30-deepseek-onboarding-credential-setup.zh.md: 2dc7e0ccf5f9a99ad35c859a7ecfb9f98d93d530
@@ -12,7 +12,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only. **One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only.
**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md). **The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step and its Models section through `slots.inject()`, so each contribution follows its declaration lifetime without making plugin load order a contract, and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md).
**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin. **The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin.
@@ -12,7 +12,7 @@ Status: implemented
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。 **Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。
**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 步骤,因此插件加载顺序不会成为契约独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。 **设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 通过 `slots.inject()` 注册 DeepSeek 步骤及其 Models 分区,使每项贡献都跟随自身的声明生命周期,不让插件加载顺序成为契约独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。
**首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。 **首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
2026-07-30-web-read-card-frontend.md: f504cab7705d03f6d3e911da05c509da50bb9abe 2026-07-30-web-read-card-frontend.md: 06559d70c655b86a6aa70c8a9e948f4f21a1f522
2026-07-30-web-read-card-frontend.zh.md: 983e03b36a3e6d6d4ec0ba416fdcca227d82bb41 2026-07-30-web-read-card-frontend.zh.md: 92fb724658d4c23b915f61efc3b482fdf1ce7c7b
@@ -16,7 +16,7 @@ The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent car
`readCardModel` is result-side only, mirroring the backend: a read call carries no content until `execute` returns, so the pending call stays a `GenericCallView` (`kind: 'read'`) and this returns null for a running read — the row keeps its args-derived summary until the result arrives. It also returns null for a settled call whose result view is not a read card, including a `card` value this UI version does not know (which arrives over the wire and cannot be trusted to be a compiled variant) and the read tool's own generic fallback for an error result. The card's banner label is the read view's `title` when the tool supplied one (the contract's replacement-title rule), otherwise the file path relativized to the session workspace so a workspace-rooted absolute path shows the same short form the row summary shows. The model copies the frozen line array into the primitive's own line shape, so the card never holds a reference into the runtime's snapshot cache. `readCardModel` is result-side only, mirroring the backend: a read call carries no content until `execute` returns, so the pending call stays a `GenericCallView` (`kind: 'read'`) and this returns null for a running read — the row keeps its args-derived summary until the result arrives. It also returns null for a settled call whose result view is not a read card, including a `card` value this UI version does not know (which arrives over the wire and cannot be trusted to be a compiled variant) and the read tool's own generic fallback for an error result. The card's banner label is the read view's `title` when the tool supplied one (the contract's replacement-title rule), otherwise the file path relativized to the session workspace so a workspace-rooted absolute path shows the same short form the row summary shows. The model copies the frozen line array into the primitive's own line shape, so the card never holds a reference into the runtime's snapshot cache.
The chat row renders the card **resident** under the summary line, capped at `CHAT_READ_MAX_LINES` (8, half the primitive's default), the same posture `BashRow` gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed `ReadRow` (registered under `read` in `apply.ts`, the load-order seam being `inject: ['slots', 'conversation']` exactly as the bash sample) whose summary is the file path as an openable host link, and `GenericToolCard`'s fallback for a read-declaring tool without its own keyed row (e.g. `web_fetch`, which classifies to the `read` variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface. The chat row renders the card **resident** under the summary line, capped at `CHAT_READ_MAX_LINES` (8, half the primitive's default), the same posture `BashRow` gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed `ReadRow` (registered under `read` through `ctx.slots.inject`, exactly as the bash sample) whose summary is the file path as an openable host link, and `GenericToolCard`'s fallback for a read-declaring tool without its own keyed row (e.g. `web_fetch`, which classifies to the `read` variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface.
Whole-row collapse/expand (defaulting every tool call to collapsed) is a separate later change that will flip every resident card at once; this note's card is resident, matching the terminal card it sits beside. Whole-row collapse/expand (defaulting every tool call to collapsed) is a separate later change that will flip every resident card at once; this note's card is resident, matching the terminal card it sits beside.
@@ -16,7 +16,7 @@ Status: implemented
`readCardModel` 只在结果侧,与后端对称:一次读取调用在 `execute` 返回前不带任何内容,因此挂起中的调用保持为 `GenericCallView``kind: 'read'`),本函数对运行中的读取返回 null —— 该行保持其从参数派生的摘要,直到结果到达。它对结果视图不是读取卡片的已结算调用也返回 null,包括本 UI 版本不认识的 `card` 值(它从线路到来、不能被信任为一个已编译的变体)以及读取工具对错误结果自己的通用回退。卡片横幅标签在工具提供 `title` 时取它(契约的替换标题规则),否则取相对于会话工作区化简后的文件路径,使工作区根下的绝对路径显示为与行摘要相同的短形式。该 model 把冻结的行数组复制进 primitive 自己的行形状,因此卡片绝不持有指向运行时快照缓存的引用。 `readCardModel` 只在结果侧,与后端对称:一次读取调用在 `execute` 返回前不带任何内容,因此挂起中的调用保持为 `GenericCallView``kind: 'read'`),本函数对运行中的读取返回 null —— 该行保持其从参数派生的摘要,直到结果到达。它对结果视图不是读取卡片的已结算调用也返回 null,包括本 UI 版本不认识的 `card` 值(它从线路到来、不能被信任为一个已编译的变体)以及读取工具对错误结果自己的通用回退。卡片横幅标签在工具提供 `title` 时取它(契约的替换标题规则),否则取相对于会话工作区化简后的文件路径,使工作区根下的绝对路径显示为与行摘要相同的短形式。该 model 把冻结的行数组复制进 primitive 自己的行形状,因此卡片绝不持有指向运行时快照缓存的引用。
聊天行把卡片**常驻**渲染在摘要行之下,上限 `CHAT_READ_MAX_LINES`8,是 primitive 默认值的一半),与 `BashRow` 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它:keyed `ReadRow` `apply.ts` `read` 键注册,加载顺序 seam 为 `inject: ['slots', 'conversation']`与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 `GenericToolCard` 对没有自己 keyed 行的读取声明工具(例如归到 `read` 变体的 `web_fetch`)的回退。详情面板以 primitive 自己的全高上限(16)渲染同一张卡片,因为面板是单次调用的阅读界面。 聊天行把卡片**常驻**渲染在摘要行之下,上限 `CHAT_READ_MAX_LINES`8,是 primitive 默认值的一半),与 `BashRow` 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它:keyed `ReadRow` `ctx.slots.inject``read` 键注册,与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 `GenericToolCard` 对没有自己 keyed 行的读取声明工具(例如归到 `read` 变体的 `web_fetch`)的回退。详情面板以 primitive 自己的全高上限(16)渲染同一张卡片,因为面板是单次调用的阅读界面。
整行折叠/展开(把每个工具调用默认折叠)是一个单独的后续改动,它会一次性翻转每张常驻卡片;本 note 的卡片是常驻的,与它旁边的终端卡片一致。 整行折叠/展开(把每个工具调用默认折叠)是一个单独的后续改动,它会一次性翻转每张常驻卡片;本 note 的卡片是常驻的,与它旁边的终端卡片一致。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md
2026-08-04-web-context-source-and-steer-marks.md: 9070ea6ed34fffecd9fd2b90275bd31155100c75
2026-08-04-web-context-source-and-steer-marks.zh.md: 9d7c7c0a34587071e281ff8b2cb77e359a1580c1
@@ -0,0 +1,49 @@
# Agent Note: Web transcript marks context source, recall, and steering
Status: implemented
English | [中文](2026-08-04-web-context-source-and-steer-marks.zh.md)
## Problem
Everything a producer adds to the model-facing conversation reached the Web transcript as one of two anonymous shapes. Every logged non-user `user/message` — the skill catalog, the runtime snapshot, reconciled `AGENTS.md` instructions, a guard notice, a subagent report, a cross-session snapshot — collapsed into one identical `上下文注入` row, so a reader could not tell what had been added without expanding each row and reading raw JSON. Mid-turn steering was worse: it rendered in exactly the bubble a turn-opening prompt uses, leaving the transcript unable to say which message interrupted a running turn.
The distinctions are already durable. `user/message.source` is the merge-extensible provenance every producer must supply, while `agent/inbox/spliced` records whether an identified message entered and left `next-turn` or `next-step`; only the presentation discarded them. The terminal transcript this Web UI replaced did name each card's producer, so the Web surface was a regression for the same log.
## Decision
The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering.
`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md).
**The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session.
`recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` today — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario.
`MessageItem` captions durable and pending steering bubbles with `插话`. The runtime replays durable `agent/inbox/spliced` events and projects a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of [no steer entry or interjection chrome](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners.
## Alternatives considered
**Localize producer names in the client.** A dictionary keyed by plugin id would read better than `@deepseek-ai/dsh-system-prompt`, but it drifts silently on every rename, needs a client change per new producer, and cannot name a producer from a foreign log at all. Provenance the log already carries is worth more than prose the client invents.
**Register presentations per source kind.** The disclosure decision deferred a keyed context-view slot until source-owned presentations emerged. Naming a row is not a distinct presentation, and a registry keyed on mounted producers would fail exactly where it matters — a resumed log whose producer is no longer mounted still has to render.
**Compute the role and label on the host.** The host would have to attach a view to each event copy, duplicating what the durable source already states and adding a wire field per context message. The projection derives it once per node instead, where the transcript's other derived facts live.
**Give steering its own row instead of a captioned bubble.** Steering is a user message that arrived mid-turn; a separate row shape would break the right-aligned reading rhythm and duplicate the bubble's copy and branch actions for no new information.
**Extend the trajectory table with the same names.** Out of scope: the table's context cell has its own text derivation, and the issue asks for the conversation surface.
## Testing
- `packages/client/runtime` unit coverage pins each provenance arm, the label fallbacks when a name field is missing, empty, or wrongly typed, the unnamed degradation for a source with no readable kind, and steering reconstruction on reset and live append paths.
- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, the roleless header, and the steering caption on both durable and pending bubbles.
- The keyless assembled-Web goldens carry the named header and the steering caption, so the assembled transcript — not only component tests — proves the marks.
## Consequences
- A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for.
- Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better name records better provenance.
- `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it.
- `SteeringMessageNode` remains a distinct presentation node even though the agent loop now records admitted steering as `user/message`; its identity comes from the durable inbox history rather than a separate message event.
- The `recall` arm has no producer in a shipped Web leaf until a host mounts `dsh-session-reference`; it is reachable only through logs written elsewhere.
@@ -0,0 +1,49 @@
# Agent NoteWeb transcript 标出上下文来源、召回与 steering
Status: implemented
[English](2026-08-04-web-context-source-and-steer-marks.md) | 中文
## Problem
生产方向模型侧对话补充的一切内容,进入 Web transcript(文本记录)后只剩两种匿名形态。每一条已记录的非用户 `user/message`——skill 目录、运行时快照、经过对账的 `AGENTS.md` 指令、guard 提示、子 agent 汇报、跨会话快照——都塌缩成同一行 `上下文注入`,读者不逐行展开去读原始 JSON 就无从知道究竟注入了什么。steering(中途引导)的情况更糟:它渲染成与开轮提示完全相同的气泡,于是 transcript 无法说明哪一条消息打断了正在运行的轮次。
这些区分本来就是持久事实。`user/message.source` 是每个生产方都必须提供的可合并扩展来源,`agent/inbox/spliced` 则记录有身份的消息是从 `next-turn` 还是 `next-step` 进入和离开;把这些事实丢掉的只有呈现层。被这套 Web UI 取代的终端 transcript 本来会写出每张卡片的生产者,因此面对同一份日志,Web 侧是一次倒退。
## Decision
transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。
`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role``inject`,跨会话快照则为 `recall`)与命名生产者的 `label``ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。
**名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。
`recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。今天没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。
`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。runtime 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[取消 steer 入口与插话装饰](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。
## Alternatives considered
**在客户端本地化生产者名称。** 以插件 id 为键的字典读起来确实比 `@deepseek-ai/dsh-system-prompt` 好,但它会在每次重命名时悄悄失准,每新增一个生产者都要改客户端,而且对来自外部的日志根本无法命名。日志已经承载的来源,比客户端自己编出来的措辞更有价值。
**按来源 kind 注册呈现。** 展开项决策把键控的 context-view 槽位推迟到出现由来源自有的呈现需求为止。为一行命名并不构成独立呈现,而以「已挂载的生产者」为键的注册表恰恰会在最要紧的地方失效——生产者已不再挂载的恢复日志同样必须渲染出来。
**在 host 侧计算角色与名称。** 那需要为每份事件副本附加一个视图,重复陈述持久来源已经说明的事实,并为每条上下文消息增加一个 wire 字段。改由投影为每个节点计算一次,与 transcript 其他派生事实同处一地。
**给 steering 独立的行而非带标注的气泡。** steering 是一条在轮次中途抵达的用户消息;独立行形会打断右对齐的阅读节奏,并且要为零新增信息重复气泡上的复制与分支操作。
**把同一套名称扩展到 trajectory 表格。** 不在本次范围内:该表格的上下文单元格有自己的文本推导,而 issue 要求的是对话面。
## Testing
- `packages/client/runtime` 单元覆盖钉住每个来源分支、名称字段缺失/为空/类型不符时的回退、来源没有可读 kind 时的无名降级,以及 reset 和实时 append 路径上的 steering 重建。
- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存、无名时的标题形态,以及持久与待处理气泡上的 steering 标注。
- 无密钥的组装 Web 黄金基线携带带名称的标题栏与 steering 标注,因此证明这些标识的是组装后的 transcript,而不只是组件测试。
## Consequences
- 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。
- 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill``@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好名称的生产者应当记录更好的来源。
- `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。
- 即使 agent loop 现在把已经接纳的 steering 记录为 `user/message``SteeringMessageNode` 仍是独立的呈现节点;它的身份来自持久 inbox 历史,而不是独立消息事件。
- 在某个宿主挂载 `dsh-session-reference` 之前,`recall` 分支在已发布的 Web 叶子中没有生产者,只能通过别处写入的日志抵达。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md
2026-08-04-web-latency-throughput-metrics.md: 4d7627a9a38127259f1ba07121cea7282f794f2e
2026-08-04-web-latency-throughput-metrics.zh.md: 09e59242b799a3f4d03b9e0259f1a5a0d85f6a53
@@ -0,0 +1,31 @@
# Agent Note: Web turn and window latency/throughput metrics
Status: implemented
English | [中文](2026-08-04-web-latency-throughput-metrics.zh.md)
## Problem
The Web chat records per-step LLM timing (`stepStartTime` / `firstTokenTime` / `completedTime`) and per-step usage, and the trajectory view exposes them per step, but the chat surface answers neither "how responsive was this turn" nor "how fast is this session going": the assistant footer shows only the turn wall time, and the stats line folds only wall-time totals.
## Decision
A package-local fold, `ui-conversation`'s `chat/turn-metrics.ts`, is the single derivation from assistant nodes to latency/throughput readings. `assistantStepReading` turns one node into a step reading: TTFT needs both `stepStartTime` and `firstTokenTime`, decode span needs `firstTokenTime`, negative spans clamp to zero, and output tokens come from the untrusted `usage` value only when they are finite and non-negative. `deriveTurnMetrics` folds readings per turn: the lowest-numbered step owns the turn's TTFT slot, and throughput divides the summed output tokens by the summed decode spans over exactly the steps carrying both, so an unsampled step drops out instead of skewing the ratio; a turn with neither figure emits no entry.
The assistant footer appends the readings to the existing hover-revealed time chrome after `Ran for`, as `TTFT {s}s · {tps} tok/s`, each omitted independently when unrecorded. ChatView shows a turn's readings only when that turn's `turnTimings` entry has an `endTime`: the loaded window is a contiguous log suffix, so an in-window settled turn carries every one of its steps and the first-step TTFT is genuine rather than a window artifact. `formatLatencySeconds` is unit-less so each locale template owns its second suffix (`TTFT {seconds}s` / `首 token {seconds}秒`).
The stats line reuses the same step reading in its window fold: `deriveStats` accumulates TTFT sum/count and decode span/tokens, rendering a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English) beside the LLM/tool wall times. The turn-count, step-count, duration, cache, and token labels use the same namespace. Like those wall times the group is window-scoped and folds no billing; token accounting stays on the token-meter projections.
## Alternatives considered
**A durable session projection (token-meter shape).** A `ProjectionDefinition` folding step timings host-side would survive compaction and window paging and cover the whole log. Deferred, not rejected: projection state must stay O(1) (averages, not percentiles), it needs a host change plus a schema, and the chat stats line is already documented as window-scoped for its duration facts — the new group joins that scope. A later PR can add the durable projection without moving these readings.
**Per-step footer chrome.** Showing each assistant message its own TTFT would attach chrome to mid-turn narration nodes, which the footer design deliberately keeps chrome-free; the trajectory view already exposes per-step timing detail.
**Gating footer metrics on node presence instead of `turn/end` timing.** Rendering whatever steps happen to be loaded would show a plausible-looking TTFT that is actually the first *loaded* step after paging. The `endTime` gate plus the suffix-window invariant makes the displayed figure the turn's true first-step latency or nothing.
## Consequences
A settled in-window turn's footer reveals `TTFT`/`tok/s` on hover after the wall time, and the stats line shows window-average latency and throughput with localized labels beside its wall times, all without new session events or host changes. Metrics degrade by omission: providers or steps without timing or usage samples drop individual figures rather than rendering zeros. Older history outside the loaded window stays uncounted, recorded in the package README's stats-line limitation.
Both readings divide by measured wall time, so neither is reproducible: the same replayed scenario yielded 69 and 70 tok/s on consecutive local runs, and a 3 ms replayed stream reads 26333 tok/s. The Web aria goldens therefore normalize throughput to `{{throughput}}` beside the existing `{{duration}}`, and the footer's decorative separators gained flanking spaces — without them the readings concatenate into one accessible string (`Ran for 13sTTFT 0.2s12 tok/s`), which both loses the reading boundaries a screen reader needs and denies `{{duration}}` the word boundary it matches on.
@@ -0,0 +1,31 @@
# Agent Note: Web 轮次与窗口级延迟/吞吐指标
Status: implemented
[English](2026-08-04-web-latency-throughput-metrics.md) | 中文
## 问题
Web 聊天已经记录了逐步骤的 LLM 计时(`stepStartTime``firstTokenTime``completedTime`)和逐步骤 usagetrajectory 视图也按步骤展示它们,但聊天界面既回答不了「这一轮响应有多快」,也回答不了「这个会话跑得有多快」:assistant 页脚只显示轮次实际耗时,统计行也只折算墙钟时间总量。
## 决策
包内折算 `ui-conversation``chat/turn-metrics.ts` 是从 assistant 节点推导延迟/吞吐读数的唯一位置。`assistantStepReading` 把一个节点转成一次步骤读数:TTFT(首 token 延迟)需要 `stepStartTime``firstTokenTime` 同时存在,解码时长需要 `firstTokenTime`,负时长收敛为零,输出 token 数只在不可信的 `usage` 值有限且非负时才采纳。`deriveTurnMetrics` 按轮次折算读数:编号最小的步骤拥有该轮次的 TTFT 槽位,吞吐用「同时携带两者的那些步骤」的输出 token 总和除以解码时长总和,因此缺采样的步骤直接退出而不是让比值失真;两个数字都没有的轮次不产生条目。
assistant 页脚把读数追加到既有 hover 显示的时间附属元素中、`用时` 之后,形如 `首 token {s}秒 · {tps} tok/s`,未记录的数字各自省略。ChatView 仅在该轮次的 `turnTimings` 条目带有 `endTime` 时才显示读数:已加载窗口是日志的连续后缀,因此窗口内已结算的轮次必然带着它的全部步骤,首步 TTFT 是真实值而非窗口截断的产物。`formatLatencySeconds` 不带单位,各语言模板各自拥有秒后缀(`TTFT {seconds}s``首 token {seconds}秒`)。
统计行在其窗口折算中复用同一份步骤读数:`deriveStats` 累计 TTFT 总和/计数与解码时长/token 数,在 LLM/工具墙钟时间旁渲染经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`)。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。与那些墙钟时间一样,该分组是窗口作用域的,不折算任何计费;token 账目仍归 token-meter 投影。
## 考虑过的替代方案
**持久的会话投影(token-meter 形态)。** 在 host 侧用 `ProjectionDefinition` 折算步骤计时可以跨越压缩与窗口分页、覆盖整个日志。是暂缓而非否决:投影状态必须保持 O(1)(只能均值,不能分位数),它需要 host 改动加 schema,而聊天统计行的耗时事实本就被记录为窗口作用域——新分组沿用该作用域。后续 PR 可以在不挪动这些读数的情况下补上持久投影。
**逐步骤页脚附属元素。** 让每条 assistant 消息显示自己的 TTFT,会给轮次中段的叙述节点挂上附属元素,而页脚设计刻意让它们保持无 chrome;trajectory 视图已经暴露逐步骤计时细节。
**用节点是否在场而非 `turn/end` 计时做页脚门控。** 直接渲染碰巧加载到的步骤,会展示一个貌似合理、实为分页后「首个已加载步骤」的 TTFT。`endTime` 门控加上后缀窗口不变量,使显示的数字要么是该轮次真实的首步延迟,要么什么都不显示。
## 后果
窗口内已结算轮次的页脚在 hover 时于实际耗时之后显示 `首 token``tok/s`,统计行在墙钟时间旁以本地化标签显示窗口平均延迟与吞吐,全程不新增会话事件、不改 host。指标以省略的方式退化:没有计时或 usage 采样的提供方或步骤只是丢掉对应数字,而不会渲染成零。已加载窗口之外的更早历史仍不计入,已记录在包 README 的统计行限制中。
两个读数都以实测墙钟时间作分母,因此都不可复现:同一个回放场景在本机连续两次跑出 69 与 70 tok/s,而一段 3 毫秒的回放流会读成 26333 tok/s。因此 Web aria golden 在既有的 `{{duration}}` 之外,把吞吐归一化为 `{{throughput}}`;页脚的装饰性分隔符也补上了两侧空格——没有它们,这些读数会连成一整串无障碍文本(`Ran for 13sTTFT 0.2s12 tok/s`),既让屏幕阅读器失去读数之间的边界,也让 `{{duration}}` 失去它赖以匹配的词边界。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md
2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238
2026-08-04-web-slash-command-fuzzy-discovery.zh.md: d3efdc23351a1b50853ee76fb731aee046004750
@@ -0,0 +1,27 @@
# Agent Note: Web slash-command fuzzy discovery
Status: implemented
English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md)
## Problem
The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command.
## Decision
The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking.
The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token.
## Alternatives considered
**Keep prefix-only matching.** Rejected because it preserves the recall failure that motivates the feature; `/cpt` cannot discover `/compact`.
**Match unordered characters or descriptions.** Rejected because unordered matches are difficult to predict, while description matches can surface commands whose visible names do not explain why they ranked.
**Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product.
## Consequences
Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged.
@@ -0,0 +1,27 @@
# Agent Note: Web 斜杠命令模糊发现
Status: implemented
[English](2026-08-04-web-slash-command-fuzzy-discovery.md) | 中文
## Problem
Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。
## Decision
`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。
评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而 space 与 Enter 裁决继续要求命令 token 精确匹配。
## Alternatives considered
**保留仅前缀匹配。** 否决,因为本功能要解决的用户无法准确回忆前缀的问题依然存在:`/cpt` 无法发现 `/compact`
**匹配无序字符或描述。** 否决,因为无序匹配难以预测,而描述匹配可能展示命令,但命令的可见名称无法解释其排名。
**使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。
## Consequences
用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包(package)测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md
2026-08-05-composer-context-meter-breakdown.md: a757bcbc8bc57f4c3a16f663a9c922155b8bb575
2026-08-05-composer-context-meter-breakdown.zh.md: 441fd3013f2db721527838955b5ce227865615f1
@@ -0,0 +1,31 @@
# Agent Note: Composer context meter with heuristic composition breakdown
Status: implemented
English | [中文](2026-08-05-composer-context-meter-breakdown.zh.md)
## Problem
The Web chat's stats line showed context occupancy as one inline figure (`Context N% of X`) among its billing groups. That answers "how full" but not "what fills it": nothing showed how the window divides between the system prompt, tool schemas, and conversation, and the one-line row has no room for that detail. The available numbers also live in two vocabularies — the provider-exact billed prompt size from `contextPressure` versus the token-meter's fixed character heuristic — and no existing surface could present composition without conflating them.
## Decision
Three cooperating pieces, one per package boundary:
`dsh-session` exports the pure `deriveEventMessage(event)` (previously reachable only as a `Session` method, which now delegates to it) so a host-side fold can price surface nodes without a `Session` instance.
`dsh-token-meter` extracts its pricing heuristic into `src/estimate.ts` and its positional surface fold into `src/surface-fold.ts` — both shared verbatim with the measurement service — and registers a third session projection, `contextBreakdown`, carrying `systemTokens` / `toolsTokens` / `messageTokens`. Envelope figures reprice last-wins on each `request/header` through `canonicalHeader`; the message figure replays `foldSurfaceTokens` over a per-node `{seq, tokens}` list, so it equals `measure().surfaceTokens` at every event boundary by construction and compaction shrinks it the way it shrinks the next request. The shared fold is total and allocation-fresh — it returns the next surface rather than mutating one — which keeps the service's validate-before-commit replay transaction intact: a throw leaves the replay cursor unmoved and the same malformed event fails identically on retry. A replace range absent from the folded surface throws: committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event.
`ui-conversation` moves context occupancy off the stats line (one home per fact) onto a composer-trailing `ContextMeter`: a 14px occupancy ring after the model seat fed by `contextPressure`, click-opening a panel that pairs the provider-exact percent and `~used / capacity` header with a 4px color-segmented bar and `~`-prefixed composition rows. The two vocabularies deliberately never reconcile — the heuristic shares only proportion the bar's colored segments and rows, each marked `~` because the fixed 4-chars-per-token heuristic systematically underprices CJK text and code. (The ring, header, and bar length were provider-exact as shipped here; they now read the provider-anchored `projectedTokens` instead, because the bare sample could not see a compaction — see [the meter's compaction blindness](../bug-fix/2026-08-05-context-meter-blind-to-compaction.md).) The header is one localized sentence (`context.aria`, shared with the ring's accessible name) split around its `{percent}` slot, so each locale owns the reading's position — English leads with it, Chinese trails it — while the reading keeps its own tone; a bar part whose width computes to zero is dropped rather than rendered, because `.segment`'s min-width would otherwise paint a filled sliver at 0% occupancy.
## Alternatives considered
**Deriving composition client-side from the loaded window.** The window is a contiguous log suffix: the `request/header` events carrying the system prompt and tool schemas may sit outside it, and paging would silently change the figures. Only a durable host-side projection survives paging and compaction, which is why the data crosses the wire as a third projection rather than a chat-window fold.
**Scaling the heuristic rows to sum to `pressureTokens`.** Forced reconciliation fabricates precision: pressure lags one request, includes provider envelope overhead the estimator never models, and would make the rows move when nothing in the composition changed. Showing the estimator's real vocabulary with an explicit `~` was chosen instead.
**Finer categories (rules, skills, MCP tools) as in Claude Code's `/context`.** Not separable here: the harness folds those contributions into the system text and the tools list before the request header exists, so three categories are the honest resolution.
## Consequences
Token-meter now registers three projection keys; unloading removes all three, and `contextBreakdown` restores from JSON checkpoints (`stateVersion` 1). The stats line dropped its Context group and the ring is the sole context UI. The panel's heuristic rows visibly disagree with the provider-exact header — accepted and signposted by the `~` prefix; improving estimate accuracy (for example CJK-aware weighting) is localized to `estimate.ts` and changes no seam. The legend's purple segment tint is a literal color because the design platform ships no purple static token.
@@ -0,0 +1,31 @@
# Agent Note: composer 上下文占用圆环与启发式组成明细
Status: implemented
[English](2026-08-05-composer-context-meter-breakdown.md) | 中文
## 问题
Web 聊天的统计行把上下文占用率作为一个行内数字(`Context N% of X`)挤在计费分组之间。它回答了「有多满」,却回答不了「被什么占满」:没有任何地方展示窗口在系统提示词、工具 schema 与对话之间如何分配,而单行统计行也容纳不下这种明细。可用的数字还分属两套口径——来自 `contextPressure` 的提供方精确计费 prompt 规模,与 token-meter 的固定字符启发式——没有任何既有界面能在不混淆两者的前提下展示组成。
## 决定
三个协作部分,每个包边界一个:
`dsh-session` 导出纯函数 `deriveEventMessage(event)`(此前只能通过 `Session` 方法访问,该方法现在委托给它),使 host 侧 fold 无需 `Session` 实例即可为表层节点计价。
`dsh-token-meter` 把计价启发式抽取到 `src/estimate.ts`、把位置表层折叠抽取到 `src/surface-fold.ts`(两者都与测量服务逐字共享),并注册第三个会话投影 `contextBreakdown`,携带 `systemTokens` / `toolsTokens` / `messageTokens`。envelope 数字在每条 `request/header` 上经 `canonicalHeader` 按后者胜重新计价;消息数字在逐节点 `{seq, tokens}` 列表上重放 `foldSurfaceTokens`,因此它在每个事件边界上按构造等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。这份共享折叠是全函数且总是新建数组——返回下一个表层而不是原地改写——从而保留了服务侧「先校验再提交」的重放事务:抛出时重放游标不前进,同一条畸形事件在重试时报同样的错。折叠表层中不存在的替换范围会直接抛出:已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。
`ui-conversation` 把上下文占用率从统计行移走(一个事实一个家),放到 composer 尾部的 `ContextMeter`:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,点击弹出的面板把提供方精确的百分比与 `~已用 / 容量` 标题,与 4px 分色分段进度条及带 `~` 前缀的组成明细行并列。两套口径刻意永不对账——启发式占比只用于切分进度条的彩色分段与明细行,且每个启发式数字都标 `~`,因为固定的「4 字符≈1 token」启发式会系统性低估 CJK 文本与代码。(本记录落地时,圆环、标题与进度条总长取的是提供方精确值;它们现在改读锚定在提供方读数上的 `projectedTokens`,因为裸样本看不见压缩——见[仪表对压缩的失明](../bug-fix/2026-08-05-context-meter-blind-to-compaction.md)。)标题是一整句本地化文案(`context.aria`,与圆环的无障碍名共用),在 `{percent}` 槽位处切开渲染,于是读数的位置由各语言自己决定——英文在前、中文在后——同时读数保留自己的字重;宽度算出为零的分段直接不渲染,否则 `.segment` 的 min-width 会在 0% 占用时画出一段填充色。
## 备选方案
**在客户端从已加载窗口推导组成。** 窗口是日志的连续后缀:携带系统提示词与工具 schema 的 `request/header` 事件可能在窗口之外,翻页还会让数字悄悄变化。只有持久的 host 侧投影能在翻页与压缩后幸存,这正是数据以第三个投影而非聊天窗口 fold 的形式过线的原因。
**把启发式明细行按比例缩放到与 `pressureTokens` 相加一致。** 强行对账是在捏造精度:压力滞后一个请求,还包含估算器从不建模的提供方封装开销,会让明细行在组成毫无变化时也跟着变动。最终选择以显式 `~` 展示估算器的真实口径。
**更细的类别(rules、skills、MCP 工具,如 Claude Code 的 `/context`)。** 在这里不可分:harness 在请求标头存在之前就把这些贡献折入系统文本与工具列表,因此三个类别是诚实的分辨率。
## 后果
token-meter 现在注册三个投影键;卸载会移除全部三个,`contextBreakdown` 可从 JSON 检查点恢复(`stateVersion` 为 1)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 `~` 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 `estimate.ts`,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md
2026-08-05-context-form-vocabulary.md: 618c11b925208d09a62d10101656e3358e327ddb
2026-08-05-context-form-vocabulary.zh.md: 66278b261f23601461c96f08e87ea58766b2bfc2
@@ -0,0 +1,73 @@
# Agent Note: Producer-declared context forms
Status: implemented
English | [中文](2026-08-05-context-form-vocabulary.zh.md)
## Problem
Every logged non-user `user/message` rendered through one body: the whole message serialized as inline JSON. A reader opening a row met `{ "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }`, where the escaping had collapsed the only thing worth reading — the model-facing prose — into a single line, and the provenance sat inside the same blob.
Naming the producer in the header (the [source and steer marks decision](2026-08-04-web-context-source-and-steer-marks.md)) fixed *who added this*. It could not fix *what kind of thing was added*, because nothing in the log said so. Injected context is not one shape: a reconciled `AGENTS.md`, a catalog of available skills, a runtime policy snapshot, and a subagent's report are as different from each other as a terminal card is from a diff card, yet all four presented as the same wall of escaped JSON.
The tool surface already solved this shape. `ToolCallView` has three cards, not one per tool, and a tool declares which card its call is. Context had no equivalent: no vocabulary of shapes, and no way for a producer to say which one it emits.
## Decision
`MessageSource` gains an optional producer-declared `form: ContextForm` — a small tagged vocabulary of information *shapes*, independent of `kind`:
- `kind` answers **who produced this** and remains pure provenance.
- `form` answers **what shape of information it is**. Several producers may share one form, and one producer may emit more than one over a session.
The vocabulary is semantic, never visual. A value states that the content is a file's instructions or a catalog of available items; colors, icons, ordering, and collapse defaults are the consumer's business and must not enter the union. It grows one value at a time, as producers gain the structured fields their form needs. This release declares two:
**`instructions`** — instructions read out of workspace files. `workspace-context` declares it on both the startup baseline and later deltas; its existing `changes[]` already carried the paths, actions, and digests the presentation needs, so no field was added. The body lists the reconciled files above the text, and keeps the `<system-reminder>` framing verbatim: the framing is part of what the model read, so hiding it would misreport the request.
**`catalog`** — a catalog of items available this session, republished as it changes. `dsh-tool-skill` moves off the shared `plugin` kind to its own `skill-catalog` source carrying `entries` (the exact `name`/`description` pairs published) and `update` on a replacement, which the body renders as a replacement notice. The body lists those entries instead of re-parsing the `<available_skills>` block out of the prose.
Entries record the published fact **unescaped**. The pseudo-XML escaping belongs to the `<available_skills>` frame, which exists for the model, so it is applied when rendering that frame and never stored; otherwise a consumer would have to know the frame's encoding to display a description containing `<`, and the same frame knowledge this decision removes would leak back in another shape. `escapeText` is deterministic and injective, so digesting the unescaped entries preserves republish semantics exactly, and the model-facing text stays byte-identical.
That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits. One case does not self-heal: if that old-format catalog is the only one and the current view has no skills, the plugin sees no published catalog and emits no tombstone, so the model keeps a stale catalog nothing replaces. The pre-release stance ("backends reject old on-disk formats") permits it; it is recorded here rather than left to the optimistic path.
**`snapshot`** — current state that a later snapshot from the same producer supersedes. The runtime-context snapshot, `time-context`, and `tmux-context` declare it. `renderContextSections()` exposes the assembly's named contributions, which `renderContextSnapshot()` already joined for the model, so the body attributes each part to the subsystem that produced it without re-splitting joined prose. The two single-contribution producers record one section each. The cleared runtime-context marker has no contributions left and declares no form.
**`notice`** — a one-off account of something that just happened. `tool-tasks`, `tool-goal` wrap-up, `plan-mode` switches, and `repeat-tool-guard` reminders declare it with a `summary`, which rides the **collapsed** row: a notice is meant to be read without expanding at all. The summary is bounded where its inputs are caller text (a task's label and status detail have no length of their own). Goal state changes remain domain-owned `goal/change` events rather than model context, so they declare no form.
**`relay`** — a message another agent addressed to this one. Both subagent-addressed sources declare it; the sender is shown as the opaque session id the source already records, because this client cannot resolve it to a title.
**`recall`** — material lifted out of another session's log. `session-reference` declares it and needed no new field: its references already record the label, retained and omitted counts, and truncation flag, which the body shows first, because recalled context is bounded on the way in and a card that hid the omitted count would overstate what the model received.
Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared.
The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point.
Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining provenance as fields. Opaque is the documented default, not a leftover bin. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer.
## Why not a presenter registry
The tool seam pairs its vocabulary with `presentCall(args)`, a host-side pure function each tool implements. Context deliberately has no equivalent, because the input differs in ownership: a tool's `args` are generated by the **model** against a model-facing schema, so a translation step is unavoidable; a context `source` is constructed by the **producing plugin** itself, under no external constraint, and can simply record the facts a presentation needs. Adding a registry would have bought a translation nobody needs, at the cost of a host computation point, a wire field per context message, and a browser bundle for every producing package (the client purity gate forbids host packages from contributing components).
## Alternatives considered
**Map source kinds to renderers in the client.** Cheapest to write and requires no format change, but it puts producer knowledge back in the client: every new kind then needs a client release to render as anything but opaque, and a foreign log cannot be classified at all. It also reintroduces exactly the coupling the [source and steer marks decision](2026-08-04-web-context-source-and-steer-marks.md) removed for labels.
**Reuse `kind` as the form.** One discriminant is simpler, and `workspace-instructions` is already 1:1 with its form. It breaks on the shared shapes: three producers emit runtime snapshots today, and folding them into one kind would erase their provenance. Two axes keep provenance exact while letting presentations be shared.
**Let the client parse the model-facing prose.** The entries and file sections are visibly structured in the text. Parsing them couples the presentation to prompt wording, so every reword silently breaks a card — the same reason catalog identity moved off the text.
**Render instructions as Markdown.** The body is a Markdown file and would read better rendered. The text also carries `<system-reminder>` framing, which the Markdown renderer drops as raw HTML, so a Markdown body would silently hide part of what the model read. Deferred until the producer records per-file content structurally.
## Testing
- `packages/client/runtime` pins the form projection, including the unknown, empty, wrongly-typed, and absent values that must degrade to opaque.
- `packages/client/ui-conversation` pins each body: the opaque body's preserved line breaks and provenance fields, the instructions body's file list and verbatim framing, the catalog body's entry list, and a catalog with unusable entries falling back to opaque.
- `packages/skill/tool-skill` pins the new source on first publication and replacement, republish behavior driven by the durable entries, and a malformed durable catalog leaving step observation intact.
- The keyless assembled-Web seeded-history scenario expands a real `instructions` context in Chromium and asserts its file list, verbatim framing, and the unchanged disclosure geometry. `catalog` has no assembled coverage: the hermetic scaffold publishes no skills, so no catalog reaches a browser scenario.
## Consequences
- A reader can tell what was added without expanding, and reading it no longer means reading escaped JSON.
- The durable `MessageSource` now carries a semantic classification beside provenance. The boundary is load-bearing: facts and shape only, never presentation. A producer that wants a better card records better facts.
- Catalog identity no longer depends on the model-facing prose, deleting the text-slicing path that could mistake a reworded catalog for a changed one.
- Every shipped producer except the two hook bridges now declares a form. The bridges stay opaque by design: their content is whatever an external program printed, so no shape can be promised for it. Unknown kinds and unreadable records land there too.
- `ContextFormed` is discriminated by `form`, so a producer cannot declare a shape without the facts that shape is presented from — a `notice` without its summary, or a `snapshot` without its sections, fails to compile.
@@ -0,0 +1,73 @@
# Agent Note:由生产方声明的上下文形态
Status: implemented
[English](2026-08-05-context-form-vocabulary.md) | 中文
## Problem
每一条已记录的非用户 `user/message` 都通过同一个内容区渲染:把整条消息序列化成内联 JSON。读者展开一行,看到的是 `{ "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }`——转义把唯一值得读的东西(面向模型的散文)压成了一行,而来源信息又和它挤在同一坨里。
在标题栏写出生产者([来源与 steer 标识决策](2026-08-04-web-context-source-and-steer-marks.md))解决了「这是谁加的」。它解决不了「加进来的是什么东西」,因为日志里根本没有这句话。注入上下文不是一种形状:对账后的 `AGENTS.md`、可用 skill 的目录、运行时策略快照、子 agent 的汇报,彼此之间的差别不亚于终端卡片与 diff 卡片,然而这四者呈现出来是同一堵转义 JSON 的墙。
工具面早就解决过同一个形状问题。`ToolCallView` 只有三种卡片,而不是每个工具一种,由工具自己声明本次调用属于哪一种。上下文没有对应物:既没有形状词汇表,生产方也无从声明自己发出的是哪一种。
## Decision
`MessageSource` 新增一个可选、由生产方声明的 `form: ContextForm`——一份关于信息**形状**的小型 tagged 词汇表,与 `kind` 相互独立:
- `kind` 回答**由谁产生**,保持纯粹的溯源语义。
- `form` 回答**这是何种形态的信息**。多个生产方可以共用一种形态,一个生产方在一次会话中也可以发出多种。
该词汇表是语义的,绝不涉及视觉。取值只陈述「内容是某个文件的指令」或「是一份可用项目录」;颜色、图标、排序、默认折叠状态归消费方管,不得进入这个联合类型。它随生产方补齐各自形态所需的结构化字段而逐个增长。本次声明两个:
**`instructions`**——从工作区文件中读出的指令。`workspace-context` 在启动基线与后续增量上都声明它;其既有的 `changes[]` 已经携带了呈现所需的路径、动作与 digest,因此没有新增字段。内容区在正文之上列出对账过的文件,并原样保留 `<system-reminder>` 包装:那层包装本就是模型读到的一部分,隐藏它会歪曲这次请求。
**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name``description` 对)与替换目录上的 `update`,后者由内容区渲染成替换提示。内容区直接列出这些条目,不再从散文里反解 `<available_skills>` 块。
条目记录的是**未转义**的发布事实。伪 XML 转义属于 `<available_skills>` 这层为模型而设的框架,因此只在渲染该框架时施加、从不存储;否则消费方要正确展示含 `<` 的描述就得知道框架的编码方式,本决策刚移除的框架知识会换一种形式泄漏回来。`escapeText` 确定且单射,故对未转义条目取 digest 与此前完全等价,重新发布语义不变,面向模型的文本逐字节不变。
这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。有一种情形不会自愈:当那份旧格式目录是唯一的一份、且当前视图没有任何 skill 时,插件看不到已发布目录,也就不会发出 tombstone,模型手里会留着一份无人替换的过期目录。发布前阶段的姿态(「后端拒绝旧的磁盘格式」)允许这一点;此处如实记录,而不是只写乐观路径。
**`snapshot`**——会被同一生产方后续快照取代的当前状态。运行时快照、`time-context``tmux-context` 声明它。`renderContextSections()` 暴露出装配时的具名贡献——`renderContextSnapshot()` 本来就是把它们拼给模型的——因此内容区能把每一段归属到产生它的子系统,而不必去切分已经拼好的散文。两个单贡献生产方各记录一段。运行时快照的「已清空」标记没有任何贡献可归属,因此不声明形态。
**`notice`**——刚刚发生了什么的一次性说明。`tool-tasks``tool-goal` 收尾、`plan-mode` 切换与 `repeat-tool-guard` 提醒都带 `summary` 声明它,而该摘要出现在**折叠态**行上:notice 的全部意义就是不展开也能读完。摘要在其输入是调用方文本时自行封顶(任务的 label 与状态 detail 本身没有长度约束)。Goal 状态变更仍是由领域层持有的 `goal/change` 事件,而非模型上下文,因此不声明 form。
**`relay`**——另一个 agent 发给本 agent 的消息。两个子 agent 定向来源都声明它;发送方以来源已记录的不透明会话 id 呈现,因为本客户端无法把它解析成标题。
**`recall`**——从另一个会话日志搬来的材料。`session-reference` 声明它,且不需要新增字段:其 references 已经记录了标题、保留与省略条数、截断标记,内容区把这些放在最前面——召回上下文在进入时是有界的,隐藏省略条数的卡片会夸大模型实际收到的内容。
两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。
生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory``agent.session.events``source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。
其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源信息列成字段。opaque 是有文档的默认,不是兜底垃圾桶。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。
## 为什么不做 presenter 注册表
工具接缝把它的词汇表与 `presentCall(args)` 配对,那是每个工具在 host 侧实现的纯函数。上下文刻意不设对应物,因为输入的归属不同:工具的 `args` 由**模型**按面向模型的 schema 生成,翻译步骤无法回避;而上下文的 `source` 由**生产方插件**自己构造,不受任何外部约束,完全可以直接记录呈现所需的事实。加一层注册表买到的是一次没人需要的翻译,代价却是一个 host 计算点、每条上下文消息一个 wire 字段、以及每个生产方包都要出浏览器 bundle(客户端纯度门禁禁止 host 包贡献组件)。
## Alternatives considered
**在客户端把来源 kind 映射到渲染器。** 写起来最省,也不用改格式,但它把生产方知识放回了客户端:此后每新增一个 kind 都要客户端发版才能渲染成 opaque 以外的东西,而外部日志根本无法分类。它还会重新引入[来源与 steer 标识决策](2026-08-04-web-context-source-and-steer-marks.md)刚为名称去掉的那种耦合。
**复用 `kind` 充当形态。** 单一判别式更简单,`workspace-instructions` 本来也与它的形态一一对应。但它在共享形状上就崩了:今天有三个生产方发出运行时快照,把它们并成一个 kind 会抹掉各自的溯源。两根轴既保住溯源的精确,又让呈现可以共享。
**让客户端解析面向模型的散文。** 条目与文件分节在文本里确实有可见结构。解析它们会把呈现耦合到 prompt 措辞上,于是每改一次文案就静默碎掉一张卡——这也正是目录身份从文本上迁走的原因。
**把 instructions 渲染成 Markdown。** 正文本来就是 Markdown 文件,渲染出来更好读。但文本同时携带 `<system-reminder>` 包装,Markdown 渲染器会把它当原始 HTML 丢弃,于是 Markdown 内容区会悄悄隐藏模型读到的一部分。推迟到生产方按文件结构化记录内容之后再做。
## Testing
- `packages/client/runtime` 钉住形态投影,包括必须降级为 opaque 的未知值、空值、类型不符与缺失。
- `packages/client/ui-conversation` 逐个钉住内容区:opaque 的换行留存与来源字段、instructions 的文件列表与原样包装、catalog 的条目列表,以及条目不可用的 catalog 回落到 opaque。
- `packages/skill/tool-skill` 钉住首次发布与替换时的新来源、由持久条目驱动的重新发布行为,以及畸形持久目录不打断步骤观察。
- 无密钥的组装 Web seeded-history 场景在 Chromium 中展开一条真实的 `instructions` 上下文,断言其文件列表、原样包装与未改动的展开项几何。`catalog` 没有组装态覆盖:隔离脚手架不发布任何 skill,因此没有目录能进入浏览器场景。
## Consequences
- 读者不展开就能知道加进来的是什么,展开之后读到的也不再是转义 JSON。
- 持久 `MessageSource` 现在在溯源之外还承载一个语义分类。这条边界是承重的:只放事实与形状,绝不放呈现。想要更好卡片的生产方应当记录更好的事实。
- 目录身份不再依赖面向模型的散文,删掉了那条可能把「改了措辞」误判为「改了内容」的文本切分路径。
- 除两个 hook 桥接外,每个已发布的生产方现在都声明了形态。桥接按设计保持 opaque:其内容是外部程序打印出来的任意文本,无法承诺任何形状。未知 kind 与不可读记录同样落在这里。
- `ContextFormed``form` 判别,因此生产方无法在缺少该形态所需事实的情况下声明它——没有 summary 的 `notice`、没有 sections 的 `snapshot`,都会编译失败。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-web-preview-product-badge.md
2026-08-05-web-preview-product-badge.md: c20dedf8caa497d17a577cf46261c6a24c09a1ce
2026-08-05-web-preview-product-badge.zh.md: c428dabf2a9a2ece2f90b0c3a527835a494c7cb7
@@ -0,0 +1,33 @@
# Agent Note: Web preview product badge
Status: implemented
English | [中文](2026-08-05-web-preview-product-badge.zh.md)
## Problem
The Web empty state does not identify the product as a preview. Users can enter the main session surface without seeing that the product is pre-release, while a deployment setting would misrepresent a product-wide lifecycle decision as an operator choice.
## Decision
The empty hero always renders a localized `Preview` / `预览版` badge beneath the headline. It has no configuration switch: preview status is one product identity shared by every deployment, not a deployment-varying tunable.
The badge keeps the business-tertiary background so both themes retain the product-blue context, and uses the theme's primary label token for text. That pairing gives ordinary 12px text sufficient contrast in both light and dark themes; the business-primary foreground is reserved for larger or non-text accents because it does not reach the required contrast on this background.
The badge leaves the product when the first tagged release removes the repository's pre-release stance, or when the owning product decision declares the preview phase complete. That change removes the badge and its locale key together rather than adding a runtime toggle.
## Alternatives considered
**Make preview status configurable.** Rejected because two deployments of the same pre-release product must not present different lifecycle identities, and a configuration field would turn product release state into an unsupported operator choice.
**Use business-primary text on the business-tertiary background.** Rejected because the resulting light- and dark-theme contrast is below the 4.5:1 requirement for the badge's 12px text.
**Hide the badge from the accessibility tree.** Rejected because preview status is product information rather than decoration; the accessible headline therefore includes the badge text.
## Consequences
Every new session exposes the same localized preview identity in visual and accessibility output. Removing preview status is an explicit product-release edit, and the badge favors readable neutral text over an all-blue treatment while retaining the business-tinted background.
## Testing
The conversation component test covers both localized badge values, and the Web lifecycle snapshots pin the English badge in the assembled empty hero.
@@ -0,0 +1,33 @@
# Agent NoteWeb 预览版产品徽标
状态:已实现
[English](2026-08-05-web-preview-product-badge.md) | 中文
## 问题
Web 空状态没有标明产品处于预览版阶段。用户可以在未看到产品尚未正式发布的情况下进入主会话界面;若改用部署设置,则会把面向整个产品的生命周期决策误表述为操作者的选择。
## 决策
空状态主视觉区始终在标题下方渲染本地化的 `Preview` / `预览版` 徽标。它没有配置开关:预览状态是所有部署共同的一项产品身份,而不是随部署变化的可调参数。
徽标沿用 business-tertiary 背景,使两套主题都保留产品蓝的视觉语境;文字则使用主题的 primary label token。这一组合让普通 12px 文字在浅色与暗色主题下都有足够的对比度。business-primary 前景色仅留给较大字号文本或非文本强调元素,因为它在该背景上达不到要求的对比度。
首个 tagged release 取消仓库的预发布立场时,或归属产品方明确决定预览阶段结束时,产品会移除该徽标。这一改动会同时移除徽标及其 locale key,而不是增加运行时开关。
## 曾考虑的替代方案
**让预览状态可配置。** 不予采纳:同一个预发布产品的两套部署不得展示不同的生命周期身份,配置字段还会把产品发布状态变成一项不受支持的操作者选择。
**在 business-tertiary 背景上使用 business-primary 文字。** 不予采纳:由此产生的浅色与暗色主题对比度低于徽标 12px 文字所要求的 4.5:1。
**在无障碍树中隐藏徽标。** 不予采纳:预览状态是产品信息而非装饰,因此无障碍标题会包含徽标文字。
## 后果
每个新会话都会在视觉与无障碍输出中呈现相同的本地化预览版身份。移除预览状态是一项显式的产品发布改动;徽标保留业务蓝色调背景,同时采用可读的中性色文字,而不是全蓝色处理方案。
## 测试
会话组件测试覆盖两个本地化徽标值,Web 生命周期快照则固定组装后空状态主视觉区中的英文徽标。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md
2026-08-06-session-completed-done-dot.md: bd6911ce137f1272090c86c029710c9f4054ee6d
2026-08-06-session-completed-done-dot.zh.md: 9ec2199a29d1307c3ebd0238e84d5f90d36fe21c
@@ -0,0 +1,25 @@
# Agent Note: Session completion dot in the sidebar
Status: implemented
English | [中文](2026-08-06-session-completed-done-dot.zh.md)
## Problem
A session the operator delegated work to and then left (switched to another conversation) gives no signal when it finishes. Its running indicator stops, but the row then looks identical to any idle session, so the operator must poll the list or discover the finished work late. The pending-interaction amber dot covers sessions that need input, not sessions whose work is simply done.
## Decision
`SessionManager` owns a client-side completion-reminder set, a sibling of the pending-interaction bit: a running→idle edge of a session that is not the selected one arms its reminder; `select()`/`selectSubagent()` consume it; starting a new run disarms it and its completion re-arms it; removal prunes it. The bit rides `SessionListEntry``SessionSummary` (optional, absent = no reminder) into the workspace browser, whose session and search rows render the existing `StateDot` `done` state — running keeps the ongoing spinner, an idle session without a reminder shows nothing — and whose hover card labels the reminder 已完成 / Completed.
The reminder is in-memory and per browser. It survives connection generations — a transport blip does not invalidate "you have not looked yet" — but not a page reload.
## Consequences
The sidebar row states become three disjoint signals: green = finished and unviewed, amber = awaiting the operator's input, blue = running. No wire, on-disk, or configuration format changes: `SessionSummary.completed` is optional, so existing consumers and test fixtures stay valid, and only the workspace browser reads it. The completion edge is detected eagerly at every list mutation and pull (a snapshot-build-time-only pass would collapse two consecutive status frames into one observation and miss the completion).
## Alternatives considered
- **Component-local UI state.** Rejected because the sidebar unmounts on collapse and multiple surfaces (grouped tree, flat list, search) need the same bit; the manager already owns the running transitions and the selection, so a manager-owned set is the one source all surfaces can project.
- **Event-driven arming from status frames only.** Rejected because a list pull can also carry a running→idle transition (a session finished while the refresh was in flight); the reminder is reconciled against every mutation and pull.
- **Persisting the reminder.** Rejected because the reminder means "you have not looked at this session yet" in this browser; reload restores the selection and the user is looking at the list again, so a durable bit would only go stale.
@@ -0,0 +1,25 @@
# Agent Note: 侧边栏会话完成提醒点
Status: implemented
[English](2026-08-06-session-completed-done-dot.md) | 中文
## Problem
操作者派发任务后切换到其他会话,原会话完成时没有任何信号。运行指示停止后,该行与普通空闲会话看起来完全一样,操作者只能反复查看列表或很晚才发现工作已完成。等待交互的琥珀点只覆盖需要操作者输入的会话,不覆盖"只是干完了活"的会话。
## Decision
`SessionManager` 持有客户端侧的完成提醒集合,与待交互位并列:非当前会话发生 running→idle 边沿时点亮其提醒;`select()`/`selectSubagent()` 消费掉提醒;重新开始一轮运行会熄灭提醒并在再次完成时重新点亮;会话被移除时清理提醒。该位经 `SessionListEntry``SessionSummary`(可选字段,缺省 = 无提醒)进入工作区浏览区,其会话行与搜索结果行渲染现有的 `StateDot` `done` 状态——运行中仍显示转圈,无提醒的空闲会话不显示任何点——悬停卡片将该提醒标注为"已完成 / Completed"。
提醒仅存在于内存中且按浏览器实例隔离。它跨连接代存活——传输抖动不会使"你还没回来看"失效——但页面刷新后重置。
## Consequences
侧边栏行状态成为三个互斥信号:绿 = 已完成且未查看,琥珀 = 等待操作者输入,蓝 = 运行中。无 wire、磁盘或配置格式变更:`SessionSummary.completed` 为可选字段,现有消费者与测试 fixture 保持有效,只有工作区浏览区读取它。完成边沿在每次列表变更与拉取时即时检测(仅在建快照时检测会把连续两个状态帧折叠为一次观察,从而漏掉完成事件)。
## Alternatives considered
- **组件本地 UI 状态。** 已拒绝:侧边栏折叠时会卸载,且多个界面(分组树、单列表、搜索)需要同一状态位;manager 本就持有运行状态迁移与选中状态,manager 持有的集合是所有界面都能投影的唯一事实源。
- **仅从状态帧做事件驱动点亮。** 已拒绝:列表拉取本身也可能携带 running→idle 迁移(刷新在途时会话已完成);提醒需对每次变更与拉取做对账。
- **持久化提醒。** 已拒绝:提醒的含义是"此浏览器里你还没查看该会话";刷新会恢复选中状态且用户正看着列表,持久化位只会过期。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 25118af755d45211a6f3ff4339f7c952168d866c 2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: e2d821f3951af472ef1a13b7b6df88a3aa96a318
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: bd2f8709814f5462afec48d30b3e4e48eb4e4848 2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: b55d4a271e0c5f2729222f4652fb0cb43e5cc9f9
@@ -22,19 +22,20 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome:
**Delete host steering entirely.** Out of scope; the user asked only for Web UI display and entry. Agent-loop drain, session events, and the wire mode remain load-bearing for ACP/TUI/automation. **Delete host steering entirely.** Out of scope; the user asked only for Web UI display and entry. Agent-loop drain, session events, and the wire mode remain load-bearing for ACP/TUI/automation.
**Hide `steering/message` from the transcript.** Would lie on replay when an external client steers; rejected in favor of a plain bubble. **Hide durable steer `user/message` content from the transcript.** Would lie on replay when an external client steers; rejected in favor of a plain bubble.
**Keep the mode parameter but only ever pass `'queue'`.** Leaves dead API surface and tests that invent `'steer'` paths the composer cannot reach. **Keep the mode parameter but only ever pass `'queue'`.** Leaves dead API surface and tests that invent `'steer'` paths the composer cannot reach.
## Consequences ## Consequences
- Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. - **Superseded in part.** Decision bullets 1 and 3 through 5 no longer describe master: composer steering shipped later, and the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) owns its caption. The current facts follow.
- Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. - Host steering ownership is unchanged: agent-loop drain, session events, and the wire mode remain load-bearing for ACP, automation, and non-Web clients.
- `ConversationService.send(text)` still takes no mode and always queues; the composer's Steer gesture uses `session.prompt(mode: 'steer')` instead.
- Durable steer `user/message` content still folds into the transcript, so an externally submitted steer stays truthful on replay. It now carries the interjection caption instead of rendering as a bare bubble.
- Non-user next-step items (`agent.inject` context: approval notices, task completion, attached snapshots) broadcast with the `context` placement and never render as pending steering bubbles; they stay invisible until claimed as durable `user/message` context cards. - Non-user next-step items (`agent.inject` context: approval notices, task completion, attached snapshots) broadcast with the `context` placement and never render as pending steering bubbles; they stay invisible until claimed as durable `user/message` context cards.
- Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one.
## Testing ## Testing
- `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, MessageItem steering arm (no 「插话」), InputBar submit. - `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, the MessageItem steering arm, InputBar submit.
- `apps/web/tests/steering.e2e.ts` keyless replay plus updated `settled.expected.md` (steer text without badge). - `apps/web/tests/steering.e2e.ts` keyless replay plus its goldens, which pin the caption.
- `packages/host/apiproxy` `session/queue` projection test asserts user-origin next-step items stay `steering` while plugin-origin items land as `context`. - `packages/host/apiproxy` `session/queue` projection test asserts user-origin next-step items stay `steering` while plugin-origin items land as `context`.
@@ -22,19 +22,20 @@ Status: implemented
**整段删除 host steering。** 超出范围;用户只要求清 Web UI 展示与入口。agent-loop 排空、session 事件与线缆 mode 对 ACP/TUI/自动化仍是承重能力。 **整段删除 host steering。** 超出范围;用户只要求清 Web UI 展示与入口。agent-loop 排空、session 事件与线缆 mode 对 ACP/TUI/自动化仍是承重能力。
**在 transcript 中隐藏 `steering/message`。** 外部客户端 steer 时回放会撒谎;改为普通气泡。 **在 transcript 中隐藏持久 steer `user/message` 内容** 外部客户端 steer 时回放会失真,因此改为普通气泡。
**保留 mode 参数但永远只传 `'queue'`。** 留下死 API 面与只会虚构 composer 到不了的 `'steer'` 路径的测试。 **保留 mode 参数但永远只传 `'queue'`。** 留下死 API 面与只会虚构 composer 到不了的 `'steer'` 路径的测试。
## 后果 ## 后果
- Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue - **部分被取代。** 决策中的第 1 条和第 3 至 5 条已经不再描述 mastercomposer steering 后来已经交付,[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)负责定义其标注。下面列出当前事实
- Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话 - host 侧 steering 的归属未变:agent-loop 排空、session 事件与线缆 mode 对 ACP、自动化和非 Web 客户端仍然必要
- `ConversationService.send(text)` 仍然不接 mode,始终排队;composer 的 Steer 手势改走 `session.prompt(mode: 'steer')`
- 持久 steer `user/message` 内容仍然折叠进 transcript,因此外部提交的 steer 会如实出现在回放中。它现在带有插话标注,而不是无标识气泡。
- 非用户来源的 next-step 项(`agent.inject` 上下文:审批通知、任务完成、附加快照)以 `context` placement 广播,绝不渲染为待处理 steering 气泡;领取为持久 `user/message` context card 前保持不可见。 - 非用户来源的 next-step 项(`agent.inject` 上下文:审批通知、任务完成、附加快照)以 `context` placement 广播,绝不渲染为待处理 steering 气泡;领取为持久 `user/message` context card 前保持不可见。
- 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。
## 测试 ## 测试
- `packages/client/ui-conversation` unitjsdom 覆盖:input machine entersink、ConversationService 路由、MessageItem steering 分支(无「插话」)、InputBar submit。 - `packages/client/ui-conversation` unitjsdom 覆盖:input machine entersink、ConversationService 路由、MessageItem steering 分支、InputBar submit。
- `apps/web/tests/steering.e2e.ts` 无密钥回放,以及更新后的 `settled.expected.md`(有 steer 正文、无徽章) - `apps/web/tests/steering.e2e.ts` 无密钥回放及其黄金基线,后者会检查插话标注
- `packages/host/apiproxy``session/queue` 投影测试断言用户来源的 next-step 项保持 `steering`,而插件来源的项落入 `context` - `packages/host/apiproxy``session/queue` 投影测试断言用户来源的 next-step 项保持 `steering`,而插件来源的项落入 `context`
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md
2026-07-25-client-settings-locale-theme.md: c86d6ac053f7bb87ce758613a5f3a0a34951e428 2026-07-25-client-settings-locale-theme.md: fdcdcff92fa324803d5f2343f3fc3d9dcca069c7
2026-07-25-client-settings-locale-theme.zh.md: 379c6d0afee71b51dd15605fcac5689fb53ee8a3 2026-07-25-client-settings-locale-theme.zh.md: b1536a1515fa7022320934dfdabf94c815e8ba04
@@ -55,11 +55,11 @@ root
└─ models (order 10) ui-models 注册 └─ models (order 10) ui-models 注册
``` ```
Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, one-call disposal; localized labels ride the label thunk from the [full-rollout note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md), not `refresh()`) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam. Section and item contributions use `ctx.slots.inject()` and do not depend on the client manifest's apply order; localized labels ride the label thunk from the [full-rollout note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md). The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam.
### Future work: promote slot declarations to first-class injectable waits ### Slot declarations are first-class injectable waits
`deferRegistration()` is behaviorally isomorphic to `ctx.inject` — one waits on a ledger declaration, the other on service presence, with matching disappear/reappear lifecycle semantics; the difference is that the fiber form's disposer lifetime naturally equals the declaration's lifetime, so the stale-disposer presence-judging machinery disappears entirely. Direction (a separate PR): SlotsService bridges each slot into a `slot:<name>` service (value = the slot spec) at declaration commit / cascade removal, registrants migrate from `deferRegistration()` to a nested `ctx.inject(['slot:<name>'], cb)`, then `deferRegistration()` is deleted and packages/client/AGENTS.md checklist item 4 is rewritten. Boundaries to pin down: the nested fiber's harmless wait must not be named by the boot fail-loud scan (needs a test); the `slot:` namespace and the silent-wait-on-typo stance; provide keys are flat names (`slot:a.b` is one key, not a property path on `ctx.slots`). This phase keeps the `deferRegistration()` function form. `SlotsService.inject()` now waits on the typed ledger key directly; it does not bridge declarations into synthetic `slot:<name>` Cordis services. The callback follows declaration collapse and redeclaration while its controller remains owned by the contributing plugin fiber, and direct registration into an undeclared slot still fails loud. This removes the stale-disposer presence machine and the typo-prone parallel service namespace. The complete lifecycle and failure contract lives in the [slot declaration injection decision](../../implemented/architecture/2026-08-05-slot-declaration-injection.md).
### Service contracts ### Service contracts
@@ -127,4 +127,4 @@ Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the on
## Risks ## Risks
The apply order of slot declarations and contributions is not fixed, so every section/item registrant must keep declaration-aware registration and judge presence by the ledger, not by a local disposer. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. The apply order of slot declarations and contributions is not fixed, so every section/item registrant must use `ctx.slots.inject()` rather than a service or local-disposer presence signal. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR.
@@ -55,11 +55,11 @@ root
└─ models (order 10) ui-models 注册 └─ models (order 10) ui-models 注册
``` ```
section/item contribution 使用 declaration-aware deferralui-slots 的 `deferRegistration()`ledger 判在位、一键 dispose(资源释放);本地化 label 走 [全量接入 Note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md) 的 label thunk,不再 `refresh()`),不依赖 client manifest(元数据清单)的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings 契约(消费 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的契约对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。 section/item contribution 使用 `ctx.slots.inject()`,不依赖 client manifest 的 apply 顺序;本地化 label 走 [全量接入 Note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md) 的 label thunk。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。
### 未来工作:slot 声明升格为可 inject 的一等等待物 ### slot 声明是一等可注入等待对象
`deferRegistration()``ctx.inject` 行为同构——一个等 ledger 声明、一个等服务在场,消失/重现的生命周期语义一致;差别只在 fiber 版的 disposer 生命周期天然等于声明生命周期,陈旧 disposer 判在位机器可整体消失。方向(另开 PR):SlotsService 在声明落账/级联拆除处把每个 slot 桥接成 `slot:<name>` 服务(value 为 slot spec),注册方从 `deferRegistration()` 迁为嵌套 `ctx.inject(['slot:<name>'], cb)`,随后删除 `deferRegistration()` 并改写 packages/client/AGENTS.md checklist 第 4 条。待钉死的边界:嵌套 fiber 的无害等待不被 boot fail-loud 扫描点名(需测试);`slot:` 名字空间与 typo 静默等待的口径;provide 键是平面名(`slot:a.b` 是一个键,不是 `ctx.slots` 的属性路径)。本期维持 `deferRegistration()` 函数形式 `SlotsService.inject()` 直接等待有类型约束的 ledger key;它不会将声明桥接为合成的 `slot:<name>` Cordis 服务。回调会跟随声明折叠与重新声明,而其控制器仍归贡献方插件 fiber 所有;直接向未声明 slot 注册仍会大声失败。这删除了陈旧 disposer 判在位机器和容易因拼写错误出错的平行服务命名空间。完整的生命周期与失败契约见 [slot 声明注入决策](../../implemented/architecture/2026-08-05-slot-declaration-injection.md)
### 服务契约 ### 服务契约
@@ -127,4 +127,4 @@ Locale 内置中文和 English`setLocale`/`setTheme` 是唯一写入口,未
## 风险 ## 风险
slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须保留 declaration-aware registration,并以 ledger(而非本地 disposer)判定在位。服务事件可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR(热模块替换)后残留。 slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须使用 `ctx.slots.inject()`,而不能以服务或本地 disposer 作为在位信号。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。
+5 -5
View File
@@ -47,6 +47,7 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
@@ -63,10 +64,14 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`koffi`](https://github.com/Koromix/koffi) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT |
| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
| [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT |
| [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT |
| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT | | [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT |
| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT | | [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT |
| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT | | [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT |
| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
@@ -75,10 +80,6 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`pnpm`](https://github.com/pnpm/pnpm) | MIT | | [`pnpm`](https://github.com/pnpm/pnpm) | MIT |
| [`react`](https://github.com/facebook/react) | MIT | | [`react`](https://github.com/facebook/react) | MIT |
| [`react-dom`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT |
| [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT |
| [`rehype-katex`](https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex) | MIT |
| [`remark-gfm`](https://github.com/remarkjs/remark-gfm) | MIT |
| [`remark-math`](https://github.com/remarkjs/remark-math/tree/main/packages/remark-math) | MIT |
| [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`supports-color`](https://github.com/chalk/supports-color) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT |
| [`tsx`](https://github.com/privatenumber/tsx) | MIT | | [`tsx`](https://github.com/privatenumber/tsx) | MIT |
@@ -109,7 +110,6 @@ External packages **directly declared** only by repository tooling, test infrast
| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+1 -1
View File
@@ -111,7 +111,7 @@ export async function runHeadless(task: string): Promise<void> {
const abort = new AbortController() const abort = new AbortController()
const frames = api.events.mux({}, abort.signal) const frames = api.events.mux({}, abort.signal)
const idle = new Promise<void>((resolve) => { const idle = new Promise<void>((resolve) => {
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', ({ agent, status }) => {
if (agent.id === created.sessionId && status === 'idle') resolve() if (agent.id === created.sessionId && status === 'idle') resolve()
}) })
}) })
+15
View File
@@ -121,6 +121,21 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 }) }, { timeout: 10_000 })
// Resolve the resident approval so the ordinary composer bar (which owns
// ContextMeter) resumes without replacing the session shell. This minimal
// boot graph intentionally does not mount the separate question UI plugin.
fireEvent.click(await screen.findByRole('button', { name: 'Allow once' }))
// The fixture mirrors all three token-meter projections, so the assembled
// ContextMeter reaches its composition panel instead of only the occupancy
// fallback path.
const contextTrigger = await screen.findByRole('button', { name: /of context used/ })
fireEvent.click(contextTrigger)
const contextPanel = await screen.findByRole('dialog', { name: 'of context used' })
within(contextPanel).getByText('System prompt')
within(contextPanel).getByText('Tools')
within(contextPanel).getByText('Messages')
// The write/edit turns render a real diff card through the assembled graph // The write/edit turns render a real diff card through the assembled graph
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the // (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
// fixture's raw text. The card is collapsed by default, so expand each edit/ // fixture's raw text. The card is collapsed by default, so expand each edit/
+8 -1
View File
@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
// Post-reload golden: the same settled conversation rebuilt purely from // Post-reload golden: the same settled conversation rebuilt purely from
// persistence + history — byte-equal rendering is exactly the recovery claim. // persistence + history — byte-equal rendering is exactly the recovery claim.
@@ -83,6 +84,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
expect(Math.abs( expect(Math.abs(
launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height, launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
)).toBeLessThan(1) )).toBeLessThan(1)
await input.fill('/cpt')
await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
'compactCompact older conversation history',
])
const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
await input.fill('') await input.fill('')
await expect.poll(() => menu.count()).toBe(0) await expect.poll(() => menu.count()).toBe(0)
}) })
@@ -258,7 +265,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([]) expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [ await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', 'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
]) ])
}) })
}) })

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