Merge branch 'master' into nih-imp-gates
This commit is contained in:
457 files changed
+22600
-3395
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-13-twin-llm-adapters.md: 5c3308b281ce71407002e95dd6e794da2a421fa8
|
||||
2026-06-13-twin-llm-adapters.zh.md: 93b084973bccaeb802508e4e939a259c281f2608
|
||||
2026-06-13-twin-llm-adapters.md: b922891d4438553fd96a7f4f4226f378e66e8ad2
|
||||
2026-06-13-twin-llm-adapters.zh.md: d98b57a0a2e7c92453046022e8cb50aa52994c0f
|
||||
@@ -12,10 +12,10 @@ English | [中文](2026-06-13-twin-llm-adapters.zh.md)
|
||||
|
||||
Ship **two** adapters against the one contract from the start, deliberately built on different internals:
|
||||
|
||||
- `dsh-llm-deepseek` — hand-rolled `fetch` + SSE parsing against the DeepSeek API.
|
||||
- `dsh-llm-deepseek` — direct `fetch` + in-repo translation against the DeepSeek API; SSE framing is delegated to `eventsource-parser` ([the SSE-parser swap](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md)). The twin identity is owning the fetch/translate internals rather than delegating to a full provider SDK, not hand-rolling transport plumbing.
|
||||
- `dsh-llm-pi-ai` — the same endpoint through the `@earendil-works/pi-ai` library (its own event vocabulary).
|
||||
|
||||
The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single hand-rolled adapter would have hidden.
|
||||
The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single direct-fetch adapter would have hidden.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -24,4 +24,4 @@ The rule they enforce: **anything the StreamChunk vocabulary cannot express for
|
||||
|
||||
## Consequences
|
||||
|
||||
The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the hand-rolled adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding Agent Note.
|
||||
The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the direct-fetch adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding Agent Note.
|
||||
@@ -12,10 +12,10 @@ Status: implemented
|
||||
|
||||
从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现构建:
|
||||
|
||||
- `dsh-llm-deepseek`:手写 `fetch` + SSE(Server-Sent Events)解析,直接对接 DeepSeek API。
|
||||
- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([SSE 解析器替换](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。
|
||||
- `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。
|
||||
|
||||
二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一手写适配器会将其隐藏。
|
||||
二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一直接 fetch 适配器会将其隐藏。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -24,4 +24,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。
|
||||
孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;直接 fetch 适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-routed-model-context-and-compaction-policy.md: b637ba24d4ba5fc25c8cdd515a821ee97883a326
|
||||
2026-07-20-routed-model-context-and-compaction-policy.zh.md: 084e762ec29ddc0aecb0bf422c147b9d3122726b
|
||||
2026-07-20-routed-model-context-and-compaction-policy.md: b00c744e4ac79983a1f492d710e7b1542278c4f4
|
||||
2026-07-20-routed-model-context-and-compaction-policy.zh.md: 88e26aa7a99e8deb434d9a8fd80784be410a999a
|
||||
+2
-2
@@ -14,9 +14,9 @@ Neither obvious configuration owner is sufficient. Compact-basic is optional and
|
||||
|
||||
### Adapters own exact-route capacity
|
||||
|
||||
`LlmAdapter.resolveModelContext(provider, model)` optionally returns `LlmModelContext` for one exact route. `LlmService.resolveModelContext()` selects the registered route owner, validates a positive integer `contextWindow`, and returns a detached value. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and `undefined` means only that the adapter cannot describe capacity.
|
||||
`LlmAdapter.resolveModel(provider, model, signal?)` returns aggregate metadata for one exact route, with optional `LlmModelContext` under its `context` field. `LlmService.resolveModelInfo()` selects the registered route owner, validates a positive integer `contextWindow`, and returns detached metadata. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and an absent `context` means only that the adapter cannot describe capacity.
|
||||
|
||||
The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or return `undefined` when it is absent. The two built-in model entries each publish an exact 128,000-token capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model.
|
||||
The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or omit `context` when it is absent. The two built-in model entries each publish an exact 128,000-token capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model.
|
||||
|
||||
### Token measurement remains model-agnostic
|
||||
|
||||
|
||||
+2
-2
@@ -14,9 +14,9 @@ Status: implemented
|
||||
|
||||
### 适配器拥有精确路由容量
|
||||
|
||||
`LlmAdapter.resolveModelContext(provider, model)` 可以为一条精确路由返回 `LlmModelContext`。`LlmService.resolveModelContext()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离值。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而 `undefined` 只表示适配器无法描述容量。
|
||||
`LlmAdapter.resolveModel(provider, model, signal?)` 返回一条精确路由的聚合元数据,其中可选的 `LlmModelContext` 位于 `context` 字段下。`LlmService.resolveModelInfo()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离的元数据。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而缺少 `context` 只表示适配器无法描述容量。
|
||||
|
||||
手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则返回 `undefined`。两个内置模型项都公开精确的 128,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。
|
||||
手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则省略 `context`。两个内置模型项都公开精确的 128,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。
|
||||
|
||||
### Token 计量保持模型无关
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-toolview-dissolution.md: a420c5945d0272cf8087d5f623e9c383c286d7c2
|
||||
2026-07-23-toolview-dissolution.zh.md: 47c1f392f5f7ddbf4e6c686b2574faa7987e6126
|
||||
2026-07-23-toolview-dissolution.md: 80c2688b152d1afe1236d4815633a5bf024db1d2
|
||||
2026-07-23-toolview-dissolution.zh.md: 928c5f445d601b2246d3ae2f9360232643814468
|
||||
@@ -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.
|
||||
|
||||
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. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar. 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: `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. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`, with a scoped badge only in child sessions). 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.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。
|
||||
|
||||
落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions` 读 `parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 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.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions` 读 `parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`,scoped badge 仅出现在子会话)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。
|
||||
|
||||
registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-24-adapter-owned-reasoning-effort-capabilities.md: cc66e4ec151fcc04445a91f4a3527cbddd130c33
|
||||
2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md: e0d28e1aca370068478e8fb1704defeaac3ab351
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Adapter-owned reasoning effort capabilities
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Reasoning strength was adapter configuration only, so a conversation could not discover or change the selected model's supported levels between requests. Promoting one adapter's level union into `dsh-llm` would make every provider and model adopt names it may not support, while a provider-specific options bag would make the loop unable to validate or durably reconstruct the effective request.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-llm` represents a reasoning effort as the opaque branded `ReasoningEffortId`. One adapter-owned `resolveModel(provider, model, signal?)` query returns `LlmResolvedModelInfo`: exact model identity plus optional context and reasoning metadata. `LlmService.resolveModelInfo()` validates and detaches that aggregate. When present, `reasoning.efforts` is a non-empty ordered list of ids with display metadata and may name one configured default. The core requires an explicit or configured effort to appear exactly in that list and never clamps or aliases a value.
|
||||
|
||||
`LlmCallConfig` and `GenerateOptions` carry the optional effort. The agent loop prepares the post-`agent/request` config under the active turn signal before writing `request/header`, so defaults and dynamic changes are model-visible only after becoming durable facts. The prepared call retains the exact adapter registration across asynchronous exact-model resolution, durable header logging, and dispatch; direct `LlmService.stream()` calls likewise capture their final registration before awaiting resolution. A route with no registered adapter retains its proposed config so an `llm/stream` middleware can own and short-circuit it; terminal dispatch still rejects an unhandled route. A resumed loop retains the logged effort only when its initial provider/model route is unchanged; a route change discards the previous model's opaque id.
|
||||
|
||||
The native DeepSeek adapter advertises `off`, `high`, and `max` when deployment policy permits thinking, and defaults to the configured effort or `high`. Its adapter-owned `off` maps to `thinking.type: disabled` with no `reasoning_effort`; `high` and `max` enable thinking and carry their official wire effort. A `thinking: disabled` deployment publishes only `off` and rejects attempts to enable thinking before provider I/O. The pi-ai adapter publishes each exact model's `getSupportedThinkingLevels()` result unchanged, including `off`, preserves an absent profile default as a provider default, and leaves provider wire-value mapping inside pi-ai. Its common stream options represent `off` by omitting `reasoning`, as required by pi-ai's own API.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Define the pi-ai `ThinkingLevel` union in core.** Rejected because current pi-ai canonical names are an adapter implementation detail; a future provider can expose a different identifier without requiring a core release.
|
||||
|
||||
**Carry an untyped provider options object.** Rejected because the loop could neither validate a selected value nor put a stable provider-neutral fact in the request header.
|
||||
|
||||
**Clamp unsupported levels.** Rejected because a silent substitution makes the user's selected control differ from the logged request intent and hides stale deployment configuration.
|
||||
|
||||
**Normalize every adapter to a core-owned level list or remove `off`.** Rejected because the selectable vocabulary belongs to the exact model capability. A client can render an adapter's `off` option without requiring every adapter to expose it.
|
||||
|
||||
## Consequences
|
||||
|
||||
Clients can query one exact route once and render its identity, context capacity, and adapter-owned reasoning choices without knowing a global enum or synthesizing `off`. Adapter configuration remains the deployment-default and policy owner, while `agent/request` can replace the effective effort on each step within that policy. Invalid exact identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`; unsupported explicit or configured values fail with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
|
||||
|
||||
The aggregate exact-model query is asynchronous and may fail for adapters backed by authoritative catalogs. Its optional signal is the caller's cancellation boundary; an asynchronous adapter must settle promptly after abort so loop disposal can reach quiescence. Keyless service, adapter, loop, session, and request-header tests pin validation, defaulting, dynamic changes, logging, resume behavior, HMR registration ownership, and cancellation; runnable snapshots pin the resolved effort in real assembled request headers, while key-gated adapter tests exercise provider serialization.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Agent Note:适配器持有的推理强度能力
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-24-adapter-owned-reasoning-effort-capabilities.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
推理强度过去只能在适配器中配置,因此对话无法在多次请求之间发现或更改所选模型支持的等级。若将某个适配器的等级联合类型提升到 `dsh-llm`,所有提供方和模型都必须采用一套自身可能并不支持的名称;若改用提供方特有的 options 对象,主循环又无法校验最终生效的请求,也无法通过持久化记录准确重建该请求。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-llm` 使用不透明的品牌类型 `ReasoningEffortId` 表示推理强度。由适配器持有的单次 `resolveModel(provider, model, signal?)` 查询返回 `LlmResolvedModelInfo`,其中包含确切模型身份以及可选的上下文和推理元数据。`LlmService.resolveModelInfo()` 会校验该聚合结果并返回分离值。`reasoning.efforts` 存在时,是包含展示元数据的非空有序 ID 列表,并可指定一个由配置确定的默认值。核心要求显式指定或配置指定的推理强度与列表中的某个 ID 完全一致,且绝不自动调整或为值提供别名。
|
||||
|
||||
`LlmCallConfig` 和 `GenerateOptions` 携带可选的推理强度。agent loop(智能体循环)在活跃轮次信号的控制下准备 `agent/request` 处理完成后的配置,再写入 `request/header`,因此默认值和动态变更只有成为持久化事实后才对模型可见。准备完成的调用在异步确切模型解析、请求头持久记录和分派全程保留同一项确切的适配器注册;直接调用 `LlmService.stream()` 时,也会在等待解析前捕获最终的适配器注册。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 中间件可以接管并短路该请求;若仍未得到处理,最终分发会拒绝该路由。恢复后的主循环仅在初始提供方/模型路由未变时保留日志中记录的推理强度;如果路由发生变化,则丢弃上一模型的不透明 ID。
|
||||
|
||||
当部署策略允许思考时,原生 DeepSeek 适配器声明 `off`、`high` 和 `max`,默认使用配置指定的推理强度,若未配置则使用 `high`。由适配器持有的 `off` 映射为 `thinking.type: disabled`,且不带 `reasoning_effort`;`high` 和 `max` 会启用思考并携带各自的官方协议强度值。配置为 `thinking: disabled` 的部署仅声明 `off`,并会在提供方 I/O 前拒绝启用思考的尝试。pi-ai 适配器原样发布每个确切模型的 `getSupportedThinkingLevels()` 结果,其中包括 `off`;profile 未指定默认值时保留提供方默认行为,并将提供方协议值的映射留在 pi-ai 内部。按照 pi-ai 自身 API 的要求,其通用流选项通过省略 `reasoning` 来表示 `off`。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**在核心中定义 pi-ai 的 `ThinkingLevel` 联合类型。** 不予采纳:pi-ai 当前的规范名称属于适配器实现细节;未来的提供方可以暴露不同的标识符,而无需为此发布新的核心版本。
|
||||
|
||||
**携带无类型约束的提供方 options 对象。** 不予采纳:主循环既无法校验选定值,也无法在请求头中写入稳定且与提供方无关的事实。
|
||||
|
||||
**自动调整不支持的等级。** 不予采纳:静默替换会导致用户选定的控制项与日志记录的请求意图不一致,还会掩盖陈旧的部署配置。
|
||||
|
||||
**将每个适配器规范化为核心持有的等级列表,或移除 `off`。** 不予采纳:可选值集合属于确切模型的能力。客户端可以渲染某个适配器的 `off` 选项,而无需要求所有适配器都暴露该选项。
|
||||
|
||||
## 影响
|
||||
|
||||
客户端只需查询一次确切路由,即可渲染其身份、上下文容量和由适配器持有的推理选项,而无需了解全局枚举或自行合成 `off`。适配器配置仍是部署默认值和策略的归属方,`agent/request` 则可以在该策略范围内为每个步骤替换实际生效的推理强度。确切身份、上下文或推理元数据无效时,分别抛出 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING`;显式指定或配置指定的值不受支持时,会在提供方 I/O 前抛出 `UNSUPPORTED_REASONING_EFFORT`。
|
||||
|
||||
确切模型元数据的聚合查询采用异步方式,并且对于由权威目录支持的适配器可能失败。可选信号构成调用方的取消边界;异步适配器必须在信号中止后迅速完成结算,使主循环的资源释放达到完全停稳。无密钥的服务、适配器、主循环、会话和请求头测试为校验、默认值解析、动态变更、日志记录、恢复行为、HMR(热模块替换)期间的注册所有权和取消提供回归保障;可运行快照锁定实际组装请求头中的已解析推理强度,仅在有密钥时运行的适配器测试则覆盖提供方序列化。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: 063494b56461593015d6de4c2b55a2d1d6a3c676
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: cd5d29dfbcd9356a9ea15852d5d27a3660084abf
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Agent Note: Web client Agent-scope parity model and the provisioning channel (agents/scope / blank reuse / provide)
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)
|
||||
|
||||
> Scope: the client Agent scope (actx) and targeted events, the client/host materialization parity model, the blank-session bit and reuse (`connectWorkspace`), the per-session provisioning channel (`sessions.provide`), the read-only queue mirror (`session/queued`), and the host wire smalls that carry these capabilities (the summary `blank` column, the `host/session-added` frame field, and the `host/commands-changed` frame). The input state machine and the slash pipeline live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); the command business surfaces live in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md).
|
||||
|
||||
## Problem
|
||||
|
||||
The web client had a single global session surface: slots all rendered from the root context, so plugins had no notion of "which agent/session is current"; the draft's true copy was buried inside the Session object, leaving any plugin that wanted to participate in input with nowhere to hook in. To support a command/input system, the platform layer first had to answer:
|
||||
|
||||
- Who owns session interaction state (menus, popups, drafts, in-flight requests), and how two sessions are structurally isolated;
|
||||
- What a "new session" is before the host entity exists — whether the client must forge an independent life for it;
|
||||
- How session-scope components fetch their own session data, instead of props passed down layer by layer;
|
||||
- What a user-abandoned new session leaves behind on the host side, and who collects it.
|
||||
|
||||
Hard constraints: the host is the single source of truth; every registration goes through a `ctx.effect` disposer; the scope mechanism matches the host's Agent scope architecture; model-visible ⟺ already in the session log.
|
||||
|
||||
## Decision
|
||||
|
||||
### The parity model: client and host share one root state axis
|
||||
|
||||
Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted):
|
||||
|
||||
- Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id.
|
||||
- The materialization moment = the instant the user picks a Workspace (cwd settled): the client calls `session.create({workspaceId})` on the spot and receives the complete entity.
|
||||
- "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text).
|
||||
- A "blank session" is just an ordinary materialized session whose log is still empty; to every Agent-scope plugin on the host (goal/plan/skill/…) it is indistinguishable from any session, so slash/plan are all naturally live.
|
||||
|
||||
### Agent scope: the actx is the sole session carrier in the client-side cordis world
|
||||
|
||||
The runtime's `agents/scope.ts` matches the host's `dsh-scope` at the mechanism layer (fiber + tag + filter; no value import: the host package carries the scoped-events `Events` merge, which would collide with the Context merge inside the client program):
|
||||
|
||||
- `createScope(ctx, key)`: a no-op plugin fiber plus `extend({[kScope]: key, [Context.filter]: …})` — the filter lives directly on the actx: untagged listeners receive globally, tagged ones receive only their own scope.
|
||||
- Dispatch is the cordis primitives with thisArg = the actx itself: `actx.bail(actx, event, req)` / `actx.emit(actx, event, payload)`.
|
||||
- `Session.bindScope(actx)`: paired exactly once when resolve mints the scope (rebinding throws; dropScope unbinds), mirroring the host's `Agent.loopCtx` — the Session uses it to dispatch its own scoped events. The reverse actx→Session direction is one hop through `sessions.sessionOf(actx)` (mirroring host plugins' `agent.session` usage).
|
||||
|
||||
Three deliberate divergences from the host dsh-scope:
|
||||
|
||||
- The filter lives on the actx itself rather than a separate carrier: the host wrapper layer guards the business Agent subject against drifting from the scope key (host events inject the Agent itself as the first argument), while client event payloads carry only an id — there is no subject to protect.
|
||||
- Keys compare by branded `SessionId` value rather than object identity: on the host, agent.id === session id (1:1 on the same axis), agent identity directly reuses the `SessionId` brand, and a client scope's identity is its wire id.
|
||||
- The client scope is an **Agent identity** scope, not a live-object scope: during a cold session the host Agent object is already disposed while the client actx stays alive (in view) — the identity axis is in strict parity while object hot/cold is deliberately unsynchronized.
|
||||
|
||||
id→ctx handoff is allowed in only three kinds of places (business providers never hand off):
|
||||
|
||||
- Slot inject factories: the ctx never enters the render layer; the identity the slot framework hands a component is the sessionId, exchanged back into objects/controllers through service maps.
|
||||
- Root coordination services self-addressing: from a projection's sessionId back to the actx via `sessions.scope(id)`.
|
||||
- Root untagged listeners: looking up their own store by the payload's sessionId.
|
||||
|
||||
### Scope lifecycle: anchored to the list mirror — birth is entering view, death is prune
|
||||
|
||||
Session instances share the scope's lifecycle; liveness eligibility = host-listed (one criterion, shared by mint and prune):
|
||||
|
||||
- Birth = a session row entering client view (the list baseline pull / the local `create()` echo / the `host/session-added` frame); a lazy first resolve mints the scope (resolution is a pure function, render-safe).
|
||||
- One prune tears down three things together: the Session instance, the scope fiber (cascading through every consumer hung on the actx), and the session-keyed slot store. The staged session (= `list.current`) is the exception: removed while still on stage, it keeps a frozen read-only view, torn down only once the stage moves away.
|
||||
- Reopening = lazily rebuilding the instance + `open()` pulling history (the host session log is the durable truth).
|
||||
- Remaining TODO: approval/question frames never enter history and cannot be recovered across a prune (the manager-level pendingBuffers cover only the never-instantiated window).
|
||||
|
||||
### The blank bit: the empty session's visible projection, conversion, and reuse
|
||||
|
||||
A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable):
|
||||
|
||||
- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk.
|
||||
- The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors).
|
||||
- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals:
|
||||
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility.
|
||||
- Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank;
|
||||
- Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank.
|
||||
- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the one with `session.id === sessions.current`, its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's current blank shows; the user-visible surface therefore holds at most one blank row globally.
|
||||
- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.
|
||||
|
||||
### connectWorkspace: the sole entry point of New Session
|
||||
|
||||
`workspaces.connectWorkspace(workspaceId): Promise<SessionId>` (owned by WorkspacesService — it holds both the workspace canonical path and the sessions reference):
|
||||
|
||||
- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path` (direct equality on the host realpath canonical form); a hit returns that id directly, creating nothing.
|
||||
- The create arm: on a miss, `session.create({workspaceId})` returns the new id.
|
||||
- An unknown workspaceId fails loud (never silently creating somewhere else).
|
||||
- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store and `sessions.binding(id)` resolves synchronously — `SessionsService.create` projects the list synchronously after RPC success before resolving, so a draft mover can write text into the new scope's machine before open, without waiting for a notifier flush.
|
||||
- The caller takes the id and does its own `sessions.open`; sending the first prompt is an ordinary `session.prompt` — the session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again.
|
||||
- The global New Session button defaults to `recentWorkspaceId`: first comparing each Workspace's newest Session `updatedAt`, falling back to the Workspace `createdAt` when it has no Sessions, and keeping host order on ties; only with no Workspace at all does it `sessions.clear()` into the no-session view. Create actions inside a Workspace group still hit that Workspace explicitly.
|
||||
- At startup the runtime subscribes to the first complete baseline: a successfully restored current session is kept in place; otherwise it automatically calls `connectWorkspace(recentWorkspaceId)` and opens the returned blank session. The policy settles only once; a later user-initiated clear is never overridden by auto-selection again, and a connect failure waits for the next baseline projection to retry.
|
||||
- Re-picking the Workspace in the blank Hero also goes through `connectWorkspace`; when the target id differs from the current one, the current input machine's non-empty draft moves to the target scope first, then `sessions.open(nextId)`. The old blank entity is not deleted — it merely leaves the list by no longer being current.
|
||||
|
||||
### Per-session provisioning: the `sessions.provide` standard-kit channel
|
||||
|
||||
The sole provisioning path by which session slot components fetch their own session data. Plugins declare a fixed key map through the static descriptor `sessions.provide({hooks, props, resolve})` (a duplicate key throws at registration); `resolve(binding)` materializes values for a specific session and tears them down with the scope. Web-react's `standardKit` single loop binds the hooks compartment into `use<Name>` selector hooks (`observableHook`→uSES, anti-tearing) and passes the props compartment through as-is.
|
||||
|
||||
Slot scope is the closed set `root | session-maybe | session`:
|
||||
|
||||
- `root` receives only the global standard kit, with no session identity or provisioning.
|
||||
- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates, while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session.
|
||||
- `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, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip.
|
||||
|
||||
- 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).
|
||||
- Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`).
|
||||
|
||||
### The read-only queue mirror
|
||||
|
||||
- The MuxFrame `session/queued`: the Session holds a read-only inbox mirror (previews truncated; steering retired by source match); queue frames never enter history — pure stream state, cleared on reconnect and refilled from the new baseline; the never-instantiated window is buffered and replayed through the manager pendingBuffers.
|
||||
- Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue.
|
||||
|
||||
### Host wire smalls
|
||||
|
||||
- The summary `blank` column and the `host/session-added` frame's `blank` field (see the blank bit above).
|
||||
- The SSE frame `host/commands-changed` (a pure invalidation signal); the client routes it into the typed events `commands/changed` and `connection/reset` (broadcast after each connection generation is established; wire-derived caches uniformly treat prior state as stale).
|
||||
- `command.list/execute` and `skill.list` are uniformly single-addressed by `sessionId` (a session always has an Agent; `agentFor`'s resume semantics come ready-made); the command-surface narrative lives in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md).
|
||||
- The `session.create` request shape: workspaceId/cwd as either-or, plus an optional caller-preallocated sessionId (a same-id same-cwd retry is idempotent; a different cwd reports `session-conflict`).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| A client-local Intent + materialize (published CAS / the pendingPrompt attach transaction / the before-create chain) | The client is forced to simulate the first half-life the host lacks, breeding a pile of state machinery — published CAS, the attach transaction, partial publication |
|
||||
| Host-reserved IDs (a draft Map) | The host merely acknowledges a number; the state machine stays on the client untouched |
|
||||
| A host draft Session (a Session without an Agent) | Every host surface that looks up the Agent must fork for drafts; core would need an attachAgent seam plus late-written header cwd |
|
||||
| Binding an Agent before cwd (ungrouped) | Overturns the readonly header.cwd "created in" invariant, plus the launch-dir side-effect product trap |
|
||||
| Passing session context down through React Context | Plugins should hold one mental model across host and client; the scope mechanism is isomorphic to the host dsh-scope |
|
||||
| A `scopeTarget` carrier + fused dispatcher (mirroring the host `agentEvents`) | The host wrapper layer guards the business Agent subject against drifting from the scope key; client events have no subject to guard — the filter on the actx plus cordis primitives covers every need |
|
||||
| Sessions not holding a ctx (a cordis-free object layer) | A red line born only so the filtering unit tests avoid importing cordis, at the cost of two-hop contribute callbacks plus mutable public fields; the host Agent already holds loopCtx |
|
||||
| Resident Session instances (resident-instance) | The host session log is the durable truth; residency is mere identity convenience, and its misalignment with the scope lifecycle is a source of complexity |
|
||||
| Components receiving wiring-callback bundles (two-layer inject→props pass-down) | The standard-kit channel lets components fetch their own; the public surface converges to hooks + stable props |
|
||||
| Swapping the no-session Hero view for the entire session Conversation | Even with the outer layout unchanged, the Hero, picker, and composer subtrees would remount together, making the whole UI region jump |
|
||||
| Making InputBar itself `session-maybe` | The input state machine, keyboard command surface, and actions would all have to accept absent values; replacing only the disabled input body keeps optionality at the shell boundary |
|
||||
| A dedicated conversion frame | `session-status(running:true)` semantically implies conversion (a blank session never runs); adding a frame buys zero information for one more wire type |
|
||||
|
||||
## Consequences
|
||||
|
||||
- Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter.
|
||||
- The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly.
|
||||
- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reclamation rides lazy persistence's existing contract (evaporation on restart), and the ordinary ceiling rides same-Workspace reuse.
|
||||
- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests; fully disabled input while no workspace is picked is an experience cost the product surface accepts (the price of the single state axis).
|
||||
- Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, awaiting its own branch).
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Agent Note: Web client Agent-scope 对等模型与供数通道(agents/scope / blank 复用 / provide)
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-25-web-client-session-scope-and-provide-channel.md) | 中文
|
||||
|
||||
> 范围:client Agent scope(actx)与定向事件、client/host 实体化对等模型、空会话 blank 位与复用(`connectWorkspace`)、per-session 供数通道(`sessions.provide`)、队列只读镜像(`session/queued`),以及承载这些能力的 host wire 小件(summary `blank` 列、`host/session-added` 帧字段、`host/commands-changed` 帧)。输入状态机与 slash 管线见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md);命令业务面见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。
|
||||
|
||||
## 问题
|
||||
|
||||
web client 只有一张全局会话面:slot 全部从根 context 渲染,插件拿不到「当前是哪个 agent/session」的语境;draft 真身埋在 Session 对象里,任何要参与输入的插件都无处下手。要支撑命令/输入体系,平台层必须先回答:
|
||||
|
||||
- 会话交互态(菜单、popup、草稿、在途请求)归谁持有,双会话如何结构性隔离;
|
||||
- 「新会话」在 host 实体存在之前是什么——client 要不要为它造一段独立生命;
|
||||
- session-scope 组件如何「自己拿会话数据」,而不是层层下传 props;
|
||||
- 用户放弃的新会话在 host 侧留下什么,谁来收。
|
||||
|
||||
硬约束:host 是唯一真源;一切注册走 `ctx.effect` disposer;scope 机制与 host 的 Agent scope 架构一致;模型可见 ⟺ 已入 session log。
|
||||
|
||||
## 决策
|
||||
|
||||
### 对等模型:client 与 host 同一根状态轴
|
||||
|
||||
host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(原子大礼包,不拆);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上):
|
||||
|
||||
- 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。
|
||||
- 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 `session.create({workspaceId})`,拿到完整实体。
|
||||
- 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何 session/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。
|
||||
- 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill/…)它与任何会话无异,slash/plan 天然全活。
|
||||
|
||||
### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体
|
||||
|
||||
runtime `agents/scope.ts` 与 host `dsh-scope` 机制层一致(fiber + tag + filter 过滤;不 value-import:host 包携带 scoped-events 的 `Events` merge,进 client program 撞 Context merge):
|
||||
|
||||
- `createScope(ctx, key)`:no-op plugin fiber + `extend({[kScope]: key, [Context.filter]: …})`——filter 直接住 actx:untagged listener 全局可收,tagged 只收本 scope。
|
||||
- 派发就是 cordis 原语,thisArg = actx 本身:`actx.bail(actx, event, req)` / `actx.emit(actx, event, payload)`。
|
||||
- `Session.bindScope(actx)`:resolve 铸 scope 时单次配对(重复绑 throw;dropScope unbind),镜像 host `Agent.loopCtx`——Session 用它自行派发 scoped 事件。actx→Session 反向走 `sessions.sessionOf(actx)` 一跳(镜像 host 插件 `agent.session` 用法)。
|
||||
|
||||
与 host dsh-scope 的有意分歧三条:
|
||||
|
||||
- filter 住 actx 自身而非独立 carrier:host 包装层护的是「业务 Agent subject 与 scope key 不漂移」(host 事件首参注入 Agent 本体),client 事件 payload 只带 id、无 subject 可护。
|
||||
- key 用品牌 `SessionId` 值比较而非对象身份:host 里 agent.id === session id(1:1 同轴),agent 身份直接复用 `SessionId` 品牌,client scope 的身份即 wire id。
|
||||
- client 是 **Agent 身份** scope 而非活对象 scope:cold 会话期 host Agent 对象已 dispose 而 client actx 存活(视野内)——身份轴严格对等、对象冷热有意不同步。
|
||||
|
||||
id→ctx 换乘只许三类位置(业务 provider 永不换乘):
|
||||
|
||||
- slot inject 工厂:ctx 不进渲染层,slot 框架交给组件的身份就是 sessionId,经服务 map 换回对象/controller。
|
||||
- root 协调服务自寻址:从投影的 sessionId 经 `sessions.scope(id)` 找回 actx。
|
||||
- root untagged listener:按 payload 的 sessionId 查自有 store。
|
||||
|
||||
### scope 生命周期:挂靠 list mirror,出生即视野、死亡即 prune
|
||||
|
||||
Session 实例与 scope 同生命周期,存活资格 = host listed(一个判据,mint 与 prune 共用):
|
||||
|
||||
- 出生 = 会话行进入 client 视野(list 基线拉取 / `create()` 本地回声 / `host/session-added` 帧),lazy 首次 resolve 铸 scope(resolution 纯函数、渲染安全)。
|
||||
- prune 一次同拆三样:Session 实例、scope fiber(级联挂在 actx 上的一切消费者)、session-keyed slot store。staged session(= `list.current`)例外:被移除仍在台上时保留冻结只读视图,stage 移走才拆。
|
||||
- 重开 = lazy 重建实例 + `open()` 拉 history(host session log 是持久真相)。
|
||||
- 遗留 TODO:approval/question 帧不进 history,跨 prune 不可恢复(manager 级 pendingBuffers 只覆盖「从未实例化」窗口)。
|
||||
|
||||
### blank 位:空会话的可见投影、转正与复用
|
||||
|
||||
「实体化但无首讯」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变):
|
||||
|
||||
- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 契约保证 never-appended 会话根本不进 `persistence.list()`(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。
|
||||
- wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。
|
||||
- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
|
||||
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、保持 connectWorkspace 复用资格。
|
||||
- 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank;
|
||||
- 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
|
||||
- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示 `session.id === sessions.current` 的一条,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。
|
||||
- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
|
||||
|
||||
### connectWorkspace:New Session 的唯一入口
|
||||
|
||||
`workspaces.connectWorkspace(workspaceId): Promise<SessionId>`(归属 WorkspacesService——它同时持有 workspace 规范 path 与 sessions 引用):
|
||||
|
||||
- 复用臂:list mirror 中找 `blank && cwd == workspace.path`(host realpath 规范 canon 直等比较),命中直接返回该 id,不新建。
|
||||
- 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。
|
||||
- 未知 workspaceId fail loud(不静默创建到别处)。
|
||||
- 解析保证(两臂同契约):promise resolve 时返回的 id 已在 list store 且 `sessions.binding(id)` 同步可解析——`SessionsService.create` 在 RPC 成功后同步投影列表再 resolve,使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。
|
||||
- 调用方拿 id 自行 `sessions.open`;首讯发送就是普通 `session.prompt`——会话本来就在,失败即普通 prompt 失败,draft 文本还在 machine 里,重试即再次发送。
|
||||
- 全局 New Session 按钮默认取 `recentWorkspaceId`:先比较各 Workspace 内 Session 的最新 `updatedAt`,无 Session 时回退 Workspace `createdAt`,同值保持 Host 顺序;只有完全没有 Workspace 时才 `sessions.clear()` 进入无 session 视图。Workspace 分组内的创建动作仍显式命中该 Workspace。
|
||||
- runtime 启动时订阅首次完整基线:若已有恢复成功的 current session 则保持不动,否则自动 `connectWorkspace(recentWorkspaceId)` 并 open 返回的 blank session。该策略只结算一次;之后用户主动 clear 不会再次被自动选择覆盖,连接失败则等下一次基线投影重试。
|
||||
- blank Hero 中改选 Workspace 也走 `connectWorkspace`;若目标 id 与当前 id 不同,先把当前 input machine 的非空 draft 搬到目标 scope,再 `sessions.open(nextId)`。旧 blank 实体不删除,只因不再 current 而从列表隐藏。
|
||||
|
||||
### per-session 供数:`sessions.provide` 标准件通道
|
||||
|
||||
session slot 组件「自己拿 session 数据」的唯一供数路径。插件以静态描述符 `sessions.provide({hooks, props, resolve})` 声明固定键表(重名 key 注册时 throw),`resolve(binding)` 在确定 session 下物化值并随 scope 拆;web-react `standardKit` 统一循环把 hooks 格绑成 `use<Name>` 选择器 hook(`observableHook`→uSES,防 tearing)、props 格原样透传。
|
||||
|
||||
slot scope 是闭集 `root | session-maybe | session`:
|
||||
|
||||
- `root` 只拿全局标准件,不接收 session 身份或供数。
|
||||
- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 驱动这条更新,`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
|
||||
- `session` 保证 `sessionId`、所有 hook 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 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`,session 出现后把输入体换成严格绑定的 InputBar;textarea 允许重建,Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内,InputBar 不因 phase 翻转而重建。
|
||||
|
||||
- runtime 内建第一条:`'session'` hook——`useSession` 本身走同一机制,无特判。
|
||||
- Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
|
||||
- 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。
|
||||
|
||||
### 队列只读镜像
|
||||
|
||||
- MuxFrame `session/queued`:Session 持只读 inbox 镜像(预览截断、steering 按 source 匹配退休);queue 帧不进 history,纯 stream 态——重连清空、新基线重灌;未实例化窗口经 manager pendingBuffers 缓冲重放。
|
||||
- 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。
|
||||
|
||||
### host wire 小件
|
||||
|
||||
- summary `blank` 列与 `host/session-added` 帧 `blank` 字段(见上文 blank 位)。
|
||||
- SSE 帧 `host/commands-changed`(纯失效信号);client 路由为类型事件 `commands/changed` 与 `connection/reset`(连接代建立后广播,wire 派生缓存一律视旧态为 stale)。
|
||||
- `command.list/execute`、`skill.list` 一律 `sessionId` 单址(会话恒有 Agent,`agentFor` 的 resume 语义现成);命令面叙述见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。
|
||||
- `session.create` 请求形状:workspaceId/cwd 二选一 + 可选调用方预分配 sessionId(同 id 同 cwd 重试幂等,异 cwd 报 `session-conflict`)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| 弃案 | 一行理由 |
|
||||
|---|---|
|
||||
| client-local Intent + materialize(published CAS / pendingPrompt attach 事务 / before-create 链) | client 被迫模拟 host 缺失的前半段生命,养出 published CAS、attach 事务、部分发布一坨状态机 |
|
||||
| host 预留 ID(draft Map) | host 只认了个号,状态机原封留在 client |
|
||||
| host draft Session(有 Session 无 Agent) | 每个查 Agent 的 host 面都要为 draft 分叉;core 要开 attachAgent 缝 + header cwd 后写 |
|
||||
| 无 cwd 先绑 Agent(ungrouped) | header.cwd readonly "created in" 不变性被推翻 + launch-dir 副作用产品坑 |
|
||||
| React Context 层层传会话语境 | 插件在 host/client 两侧应是一个心智模型;scope 机制与 host dsh-scope 同构 |
|
||||
| `scopeTarget` carrier + 融合派发器(镜像 host `agentEvents`) | host 包装层护的是「业务 Agent subject 与 scope key 不漂移」,client 事件无 subject 可护;filter 住 actx + cordis 原语覆盖全部需求 |
|
||||
| Session 不持 ctx(对象层 cordis-free) | 只为筛选单测不引 cordis 而生的红线,代价是 contribute 两跳回调 + 可变公有字段;host Agent 本就持 loopCtx |
|
||||
| Session 实例常驻(resident-instance) | host session log 即持久真相;常驻仅为身份便利,与 scope 生命周期错位是复杂度之源 |
|
||||
| 组件收 wiring 回调包(inject→props 两层下传) | 标准件通道让组件自取;公共面收敛为 hooks + 稳定 props |
|
||||
| Hero 无 session 视图与 session Conversation 整支互换 | 即使外层 layout 不变,Hero、picker 与 composer 子树仍会一起重建,界面产生整块抖动 |
|
||||
| 让 InputBar 自身变成 `session-maybe` | 输入状态机、键盘命令面与动作都被迫接受缺省值;只替换 disabled 输入体能把可选性留在外壳边界 |
|
||||
| 专用「转正」帧 | `session-status(running:true)` 语义蕴含转正(blank 会话从不 running),加帧是 wire 多一型换零信息 |
|
||||
|
||||
## 后果
|
||||
|
||||
- 插件获得与 host 同构的会话语境:per-session 状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。
|
||||
- client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等 provider 一律以 sessionId 直接寻址。
|
||||
- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有契约(重启蒸发),常规上限靠同 Workspace 复用。
|
||||
- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住;「未选 workspace」期间输入全禁是产品面接受的体验代价(单一状态轴换来的)。
|
||||
- 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,等独立分支)。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b
|
||||
2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# Agent Note: Web command business surfaces and assembly (ui-command / ui-skill / ui-subagent)
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-25-web-command-surfaces-and-assembly.zh.md)
|
||||
|
||||
> Scope: the command directory cache and three-kind dispatch (ui-command), the popup selection flow, the two skill / subagent reference sources, and fixture command routing plus assembly acceptance (the slash-flow snapshot). The carrying wire lives in the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md); triggers, the menu, and the input machine live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md).
|
||||
|
||||
## Problem
|
||||
|
||||
The pipeline was ready but command knowledge had no landing spot: host-side `ctx.commands` and `ctx.skills` were complete while the web channel had no command capability. The business layer had to answer:
|
||||
|
||||
- Command UI takes more than one shape (execute on the spot, pop a select box, backfill and keep typing arguments) — how do business packages ship with zero skeleton changes;
|
||||
- When is the directory fetched: pulling on every menu open is too slow, while a resident cache needs invalidation and reconnect stories;
|
||||
- Sessions are always agent-backed (Session + Agent born in the same instant) — by what address does the client command surface honor the host's per-agent effective directory;
|
||||
- Assembly-level acceptance: with the layers split apart, how the user-visible main chain is pinned once they come together.
|
||||
|
||||
## Decision
|
||||
|
||||
### ui-command: a `CommandService` + a session-keyed `CommandDirectory` + a per-session `PopupSelectController`
|
||||
|
||||
- The `ClientSessionContext { sessionId }` projection is self-held in the ui-slash contract (types.ts): sessions are always agent-backed, so session identity is the entire projection of command capability; the wire addresses by `{sessionId}` (both `command.list` and `command.execute`; the host resolves the Agent from the session header).
|
||||
- The directory is compartmented by `SessionId`, with per-key single-flight + an epoch guard (an old pull never overwrites newer state); `commands/changed` soft-invalidates every key (the old snapshot keeps serving while the repull runs in the background), `connection/reset` hard-invalidates every key and rewarms, Enter strong-waits on the current key, and a failure keeps the draft with no downgrade. Prewarming hangs on the source's `warm` hook — once over the full roster at scope birth, which covers the entire session lifecycle (session capability is constant from birth).
|
||||
- `register(contribution)` registers client commands (a descriptor + `available(projection)` + a popupSelect spec); candidate synthesis = the host directory + contribution availability filtering, then the query/position pass, and a host/contribution name clash fails loud.
|
||||
- The three command kinds derive from the registration surfaces; developers never declare positions: a host descriptor with `input` = **leadingInput** (backfill `/name ␣` + claim, keep typing arguments, leading position only); a client-registered popupSelect spec = **popupSelect** (the official select-box shell, business ships zero components); neither = **execute** (run on selection, zero UI).
|
||||
- The dispatch decision table: the menu can trigger all three kinds; Space recognizes only leadingInput (the misfire defense: irreversible side effects keep explicit entry points only); Enter runs execute / opens the shell only on a bare token, while leadingInput tolerates trailing arguments.
|
||||
- The popup from `popupFor(actx)`: search filters locally, select is single-flight, the projection is captured at open, onSelect consumes the token through the consume-token event only on success, a failure is retained for retry, and a session switch merely hides it. The popup shell is a transient layer (never in the state machine): the box holds focus, Enter/↑↓/Escape belong to it, and clicking outside the box dismisses (clicking the textarea also returns focus).
|
||||
|
||||
### Reference sources (seeing only projections plus their own apply closures, on the root ctx)
|
||||
|
||||
- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association).
|
||||
- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream).
|
||||
|
||||
### Fixture command routing and assembly
|
||||
|
||||
- The connection fixture adds command routing (fixture + fake-api): the keyless rig can run the complete command flow (directory, execution, popup selection).
|
||||
- The apps/cli assembly mounts all the new packages; the tsconfig path map / reference sets are filled in; catalogs/docs are regenerated with the wire and events.
|
||||
|
||||
### Assembly-level acceptance: the slash-flow snapshot
|
||||
|
||||
`apps/web/tests/slash-flow.snapshot.ts` pins the user-visible main chain (assembled keyless; package mocks are no substitute for the assembled transcript): the composer disabled with no session → creating a Workspace and entering an already-materialized blank session → picking the `/echo` leadingInput from the `/` menu → the command executes but the blank bit does not flip and the list still shows `New Session` → the first ordinary prompt's successful acceptance converts that same row; the same session-bound textarea holds across blank → active. `workspace-flow.snapshot.ts` separately pins blank-row creation/reuse, first-prompt rejection backfill, and — on a Workspace switch before the first prompt — the draft moving across input machines with the old blank row hidden.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| Inline prompt dispatch (command text riding the message into the host for parsing) | Conflates the command and message planes; command execution being independent of the message queue is existing host semantics |
|
||||
| A bridge materializing skills as commands | Skills have their own directory; N registrations would be a detour; the tag form naturally avoids the command plane |
|
||||
| A `skill.invoke` RPC | The host has no such operation; skill references are plain text riding prompts |
|
||||
| A new ContentBlock reference type | Full-chain cost (adapters/UI/compaction); text-as-truth plus structured occurrence records suffices |
|
||||
| Client packages self-reporting command directories | The host is the single source of truth; the client only reads descriptors, with `commands-changed` pushing invalidation |
|
||||
| The `requires: 'none' \| 'agent'` discriminant axis (an agentless directory + dual-addressed queries) | With sessions always agent-backed, the amphibious command has no owner; the whole axis reverts to master's shape, to be reopened on real demand |
|
||||
| Dedicated commandresult / commandpanel slots | Results go through notices; the popup shell is a skeleton-internal overlay; rich result cards sit in the ledger |
|
||||
| An agent-type directory as the `@` source | No type registry exists; the live-session snapshot already covers it |
|
||||
| A PickAction/EnterCommand class family (class-inheritance pick products) | Cross-package runtime values break client bundle purity; pure data interfaces plus closure methods are equivalent |
|
||||
|
||||
## Consequences
|
||||
|
||||
- Shipping a business command = a host registration plus one client `command.register` (popupSelect) or zero registration (execute/leadingInput derive automatically), with zero skeleton changes; the cost is that the three-kind semantics concentrate in ui-command, and a hypothetical fourth kind means changing it.
|
||||
- The resident directory cache plus push invalidation buys zero-latency menus and reliable enter adjudication; the cost is three invalidation paths (the change frame, reconnect, the epoch guard) that all need tests pinning them.
|
||||
- sessionId addressing puts the host's per-agent effective directory (global + scoped shadows) straight on the wire, with the client presenting it as-is.
|
||||
- Known gaps: the popupSelect shell has no shipped business consumer yet (model selection and its kin return with #600's host `selectModel` in live-mutation shape, serving as the onboarding template then); the queue's second cut (per-item Inbox operations), rich result cards, and roster configurability sit in the ledger awaiting their triggers.
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# Agent Note: Web 命令业务面与装配(ui-command / ui-skill / ui-subagent)
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-25-web-command-surfaces-and-assembly.md) | 中文
|
||||
|
||||
> 范围:命令目录缓存与三型判定(ui-command)、popup 选择流、skill / subagent 两个引用源、fixture 命令路由与装配验收(slash-flow 快照)。承载 wire 见[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md);触发/菜单/输入机器见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md)。
|
||||
|
||||
## 问题
|
||||
|
||||
管线就绪但没有命令知识的落点:host 侧 `ctx.commands` 与 `ctx.skills` 完整而 web 通道无命令能力。业务层要回答:
|
||||
|
||||
- 命令 UI 不止一种形态(当场执行、弹选择框、回填后继续打参数)——业务包如何零骨架改动上架;
|
||||
- 目录何时拉取:每次开菜单现拉太慢,常驻缓存就要有失效与重连故事;
|
||||
- 会话恒 agent-backed(Session+Agent 同瞬出生),client 命令面以什么地址兑现 host 的 per-agent 有效目录;
|
||||
- 装配级验收:拆开的各层合起来,用户可见主链如何钉住。
|
||||
|
||||
## 决策
|
||||
|
||||
### ui-command:`CommandService` + session 键控 `CommandDirectory` + per-session `PopupSelectController`
|
||||
|
||||
- 投影 `ClientSessionContext { sessionId }` 自持于 ui-slash 契约(types.ts):会话恒 agent-backed,会话身份即命令能力的全部投影;wire 以 `{sessionId}` 寻址(`command.list` / `command.execute` 均是;host 从会话 header 解析 Agent)。
|
||||
- 目录按 `SessionId` 分格,per-key single-flight + epoch guard(旧拉取永不覆盖新态),`commands/changed` 全 key 软失效(旧快照继续服务、后台重拉)、`connection/reset` 全 key 硬失效并预热,Enter 强等当前 key、失败留草稿不降级。预热挂 source 的 `warm` 钩子——scope 出生时对全 roster 一次,即覆盖整个会话生命周期(会话能力自出生恒定)。
|
||||
- `register(contribution)` 注册 client 命令(descriptor + `available(projection)` + popupSelect spec);候选合成 = host 目录 + contribution 可用性过滤,再过 query/position,host/contribution 重名 fail loud。
|
||||
- 命令三型按注册面派生,开发者不声明位置:host descriptor 带 `input` = **leadingInput**(回填 `/name ␣` + claim,继续打参数,仅限行首);client 注册 popupSelect spec = **popupSelect**(官方选择框壳,业务零组件);两者皆无 = **execute**(选中即执行,零 UI)。
|
||||
- 判定决策表:菜单可触发三型;Space 只认 leadingInput(误触发防线:不可逆副作用只留显式入口);Enter 裸 token 才 execute/开壳、leadingInput 容忍尾随参数。
|
||||
- `popupFor(actx)` 的 popup:search 本地过滤、select single-flight、open 时捕获投影、onSelect 成功才经 consume-token 事件消 token、失败保留可重试、session 切换只隐藏。popup 壳是瞬态层(不进状态机):框持焦点、Enter/↑↓/Escape 归它、点框外即 dismiss(点 textarea 同时归还焦点)。
|
||||
|
||||
### 引用源(只见投影 + 自家 apply 闭包的 root ctx)
|
||||
|
||||
- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。
|
||||
- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。
|
||||
|
||||
### fixture 命令路由与装配
|
||||
|
||||
- connection fixture 补命令路由(fixture + fake-api):keyless 台架可跑完整命令流(目录、执行、popup 选择)。
|
||||
- apps/cli 装配挂全部新包;tsconfig path map / reference 集补齐;catalog/docs 随 wire 与事件再生成。
|
||||
|
||||
### 装配级验收:slash-flow 快照
|
||||
|
||||
`apps/web/tests/slash-flow.snapshot.ts` 钉住用户可见主链(assembled keyless,包 mock 不替代装配转录):无 session 时 composer 禁用 → 创建 Workspace 并进入已实体化的 blank session → `/` 菜单选 `/echo` leadingInput → 命令执行但 blank 位不翻转、列表仍显示 `New Session` → 首条普通 prompt 成功受理后同一行转正;同一 session-bound textarea 跨 blank → active 保持。`workspace-flow.snapshot.ts` 另钉住 blank 行创建/复用、首讯拒绝回填,以及首讯前切换 Workspace 时 draft 跨 input machine 搬运且旧 blank 行隐藏。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| 弃案 | 一行理由 |
|
||||
|---|---|
|
||||
| prompt 内联派发(命令文本随消息进 host 解析) | 混淆命令/消息平面;命令执行独立于消息队列是既有 host 语义 |
|
||||
| skill 物化为 command 的桥 | skill 自有目录;N 笔注册是绕路;标签形式天然避开命令平面 |
|
||||
| `skill.invoke` RPC | host 无此操作;skill 引用是随 prompt 的普通文本 |
|
||||
| 新 ContentBlock 引用类型 | 全链路成本(adapter/UI/compaction);文本即真身 + 结构化 occurrence 记录已足够 |
|
||||
| client 各包自报命令目录 | host 是唯一真源;client 只读 descriptor,`commands-changed` 推失效 |
|
||||
| `requires: 'none' \| 'agent'` 判别轴(agentless 目录 + 双址查询) | 会话恒 agent-backed 后两栖命令无 owner;整轴回退 master 形状,待真需求重开 |
|
||||
| 专用 commandresult / commandpanel 坑位 | 结果走 notice;popup 壳是骨架内浮层;富结果卡入台账 |
|
||||
| agent-type 目录做 `@` 源 | 无类型注册表;live-session 快照已覆盖 |
|
||||
| PickAction/EnterCommand 类族(类继承 pick 产物) | 跨包运行时值破坏 client bundle 纯度;纯数据接口 + 闭包方法等价 |
|
||||
|
||||
## 后果
|
||||
|
||||
- 业务命令上架 = host 注册 + client 一笔 `command.register`(popupSelect)或零注册(execute/leadingInput 自动派生),零骨架改动;代价是三型语义集中在 ui-command,假想的第四型意味着改它。
|
||||
- 常驻目录缓存 + 推失效换来菜单零延迟与回车裁决可靠;代价是三条失效路径(change 帧、重连、epoch guard)都需测试钉住。
|
||||
- sessionId 寻址让 host 的 per-agent 有效目录(全局 + scoped shadows)直接上 wire,client 原样呈现。
|
||||
- 已知欠账:popupSelect 壳暂无已上架业务消费者(模型选择等 #600 的 host `selectModel` 以 live-mutation 形态回归,届时作接入样板);队列第二刀(逐项 Inbox 操作)、富结果卡、roster 可配置性入台账待触发。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: acbd132a5fdb97a4098064aae689dfca604ad4b7
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 158650a41b47f98037a1b3e610d9294694c55a8c
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# Agent Note: Web input state machine, composer slots, and the slash pipeline (ui-conversation input / ui-slash)
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-25-web-input-machine-and-slash-pipeline.zh.md)
|
||||
|
||||
> Scope: the input state machine (the occurrence table + claim watch + the submit transaction), the hub/facade and send orchestration, the three scoped bail events for cross-plugin input rewrites, `/` and `@` trigger detection and the menu pipeline (ui-slash), and the slot system around the composer. It depends on the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md)'s sctx / provide / session-maybe and blank entity model; command knowledge (the three kinds, the directory, popups) is untouched here — that is the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md)'s territory.
|
||||
|
||||
## Problem
|
||||
|
||||
Two composers, each a law unto itself: hero (EmptyState, the controlled chain writing straight into the Session) and the in-conversation InputBar (a plain controlled textarea) — behavior, draft ownership, and send path all inconsistent. To bring the three trigger families — `/` commands, skill references, `@` references — onto the input surface, these had to be answered:
|
||||
|
||||
- How the three trigger families layer, and who holds knowledge of "commands" versus who stays zero-knowledge;
|
||||
- How the input box expresses "command mode" — derived from the draft text or explicit state? What do backspace, enter, space, and pasting a whole line each mean;
|
||||
- Submission is an asynchronous transaction (an RPC round trip) — how are stale-result backwash, session switching, and React concurrent replay defended;
|
||||
- How reference chips are represented on a plain textarea, and who owns undo / clipboard / paste matching / model serialization;
|
||||
- How cross-plugin input rewrites (menu backfill, reference insertion, token consumption) achieve dependency inversion;
|
||||
- Which React shells must be reused across no session → blank session, and which strict-session input bodies may be replaced.
|
||||
|
||||
Hard constraints: components mount through slots only; presentation artifacts never enter the session log; the keyboard path is IME-safe throughout.
|
||||
|
||||
## Decision
|
||||
|
||||
### The input state machine (`InputMachine`)
|
||||
|
||||
A pure state machine, events in / effects out, clock injected. Four phases (plain / adjudicating / claimed / submitting). Command mode is **never derived from the draft**; the pick paths establish it explicitly at discrete moments; the claim is watched by `draft.startsWith(token)`, with a backspace break releasing automatically; the claim shape is `{token, hint?}` (hint feeds ghost text).
|
||||
|
||||
The event surface (`dispatch(ev)` is the single write entry; one transaction per event):
|
||||
|
||||
- `draft-changed {draft, editRange?}` — the textarea's full draft; editRange narrows the occurrence-shift computation, defaulting to a shared prefix/suffix scan.
|
||||
- `newline {selection}` — the Ctrl+Enter line break (not via the browser's execCommand: under self-managed undo a browser write forks two histories).
|
||||
- `begin-command {claim, span}` / `insert-ref {reference, span}` / `consume-token {guard}` — the machine side of the three bail events; span CAS = draftRev equality.
|
||||
- `set-invalid {invalidIds}` — the style bit for owner-resolution results (not a transaction).
|
||||
- `undo` / `redo` — the self-managed transaction log (a ring of 100; single-character typing merges within injected-clock windows; a successful submit clears the log).
|
||||
- `paste-begin {text, selection, components?, generation?}` — the paste plus hot-snapshot synchronously matched components in one transaction (one Undo returns to before the paste); opens a PasteMatchAttempt.
|
||||
- `paste-upgrade {attemptId, span, reference}` — an asynchronous match upgrade as its own transaction (Undo in two steps); the attempt stays current, and insertedRange shrinks with each upgrade.
|
||||
- `invalidate-paste` — attempt-ending gestures observed at the DOM layer (caret/selection operations and the like).
|
||||
- `enter {mode}` / `adjudicated` / `adjudication-failed` / `submit-settled` / `release` — the submit-transaction plane: a SubmitAttempt (seq + AbortSignal) blocks backwash; success commits and clears the draft; failure rolls back under the drift guard (the enter-time snapshot is backfilled only while the live draft still equals it; if the user has typed again, only a notice fires).
|
||||
|
||||
The effect surface (executed by the shell): `adjudicate` (calls SlashController.adjudicate), `begin-submit` (the claim.submit transaction), `default-sink` (ordinary messages, hub-orchestrated), `notice`.
|
||||
|
||||
The occurrence table and the chip's three projections:
|
||||
|
||||
- Each reference occupies one `U+FFFC` in the draft; a table entry is `{occurrenceId, source, ref, offset, label, clipboardText, invalid?}`; same-named chips stay independent through occurrenceId.
|
||||
- Every edit updates the draft and the table in one transaction: ranges shift; a deletion/replacement intersecting a placeholder acts on the whole chip.
|
||||
- The single-character placeholder makes keyboard atomicity mostly hold natively (the caret has no interior position; Backspace / arrow keys / Shift extension natively take the whole chip); a mouse click on a chip goes backdrop hit → whole-chip setSelectionRange.
|
||||
- The visual projection = label: the backdrop renders the chip at the placeholder offset (the textarea glyph is invisible), with invalid taking the invalid style.
|
||||
- The clipboard/persistence projection = clipboardText: copy/cut expands placeholders inside the selection; the draft-persistence mirror writes the same projection (the chat store always holds plain text; the refresh seed semantics = select-all copy → reopen → paste, with chips degrading to text across a refresh).
|
||||
- The model projection = generated per chip at submit through the source's `codec.serialize` (owned by the submit attempt's signal and stale guard; a missing owner / failure / cancel means no send, never a downgrade to `/name`).
|
||||
|
||||
### Cross-plugin input rewrites: three scoped bail events
|
||||
|
||||
The contract is declared in ui-slash (the bottom of the dependency chain); producers dispatch via `sctx.bail(sctx, ...)`, and the only consuming side is the three listeners the hub hangs on the sctx when building the shell; returning `true` ⟺ the machine passed the phase and CAS guards and actually rewrote (emitting the event ≠ a successful modification; whether Space gets `preventDefault` follows the return value):
|
||||
|
||||
- `slash/input-begin-command` `{claim, span}` — backfill of the command claim adjudicated from a menu pick / Space (dispatched by the SlashController).
|
||||
- `slash/input-insert-reference` `{reference, span}` — reference chip insertion (dispatched by the SlashController).
|
||||
- `slash/input-consume-token` `{guard: span | bare-token}` — consuming the command token after business success (dispatched by the downstream command surfaces).
|
||||
|
||||
Calls that stay un-evented (registry registration → explicit call → await): Input's own draft/submit, asynchronous Enter adjudication, the reference serializer, the asynchronous paste matcher. `@mode bail` has entered the JSDoc parser and the cordis catalog gate (scripts/jsdoc.ts).
|
||||
|
||||
### The slash pipeline (ui-slash: a root `SlashService` + a per-session `SlashController`)
|
||||
|
||||
A trigger/menu/pick pipeline with zero knowledge of "commands":
|
||||
|
||||
- The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique, registration order = group order = polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in registration order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
|
||||
- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
|
||||
- Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core.
|
||||
|
||||
### hub / facade: the resident shell and the strict-session input body
|
||||
|
||||
- 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.
|
||||
- With no session the shell renders the presentation-only `DisabledInputBar`; once `connectWorkspace` returns a blank session, only the input body is swapped for the strict-session InputBar. The textarea may be rebuilt here, while `ConversationRoot`, the Hero, and the layout skeleton hold; blank → engaging/active stays the same session-bound InputBar, with the textarea never rebuilt on a phase flip.
|
||||
- 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.
|
||||
- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt {mode:'queue'|'steer'}`; 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.
|
||||
- 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.
|
||||
|
||||
### Plain-text references (Decision 21): text outcomes and lexicon decoration
|
||||
|
||||
skill/@subagent references skip the placeholder + occurrence identity chain — a pick inserts the literal `/name ` `@name ` text straight into the draft, with the chip visual purely derived:
|
||||
|
||||
- PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
|
||||
- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the controller aggregates it into the `lexicon()` public surface.
|
||||
- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan.
|
||||
- Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
|
||||
- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Known limitation kept as-is: with the lexicon not warm at paste / cold start there is no decoration — it lights up only after typing `/` opens the menu once.
|
||||
|
||||
### Per-session provide contributions and the private keyboard surface
|
||||
|
||||
- ui-conversation (the hub doubling as a contributor) supplies through `sessions.provide` the `'input'` hook (machine state + the queue overlay) plus the `inputActions` prop (`setDraft`/`submit`, stable void callbacks).
|
||||
- The public/private boundary: the public provide carries only React-vocabulary members; the keyboard/DOM command surface (track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror — synchronous return values, disposer semantics) is InputBar-exclusive, passed privately in-package through the InputBar entry's own inject, never leaving the plugin boundary.
|
||||
|
||||
### 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.session` (single) — the strict-session header, view ring, and chat store; 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.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.composer.dock` — the stats band on the composer's top edge.
|
||||
- `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions.
|
||||
- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback.
|
||||
- `conversation.hero.workspace` (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through `connectWorkspace`, moving the draft where necessary before switching current.
|
||||
|
||||
### Testing discipline
|
||||
|
||||
The state machine's entire behavior is covered by pure-JS unit tests (event sequences in, asserting state and effects, zero browser DOM); the interaction matrix is projection-tested row by row. This requirement is precisely what forced the pure-core + service-shell layering.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| An ActiveCommand intermediate state / a registerMode mode registry / deriving command mode from the draft | Claims are established explicitly by the pick paths — no table, no derivation |
|
||||
| Direct bindTarget/bindDraft object wiring | Reverse coupling plus root-singleton cross-session mispairing; scoped bail events preserve dependency inversion with structurally correct routing |
|
||||
| A unified slash/input-apply, or eventing everything | Three independent payloads cover the cross-plugin rewrites; asynchronous paths stay registry-based explicit calls |
|
||||
| contenteditable / a rich-text tree | Poor compatibility; textarea + U+FFFC + the occurrence table covers the full interaction contract |
|
||||
| Dual draft persistence {text, occurrences} | The mirror writing the clipboard projection adds zero new concepts; chip degradation across refresh is acceptable |
|
||||
| The native textarea undo stack | Unreliable under controlled + programmatic writes; the paste two-step undo semantics can only be self-managed |
|
||||
| The InputBar receiving a 16-member wiring-callback bundle | The consumption matrix proved 11 members InputBar-exclusive and 1 a dead member; the standard-kit channel lets components fetch their own, with the keyboard surface passed privately in-package |
|
||||
| Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only |
|
||||
| A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning |
|
||||
| A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth |
|
||||
| All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity |
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# Agent Note: Web 输入状态机、composer 坑位与 slash 管线(ui-conversation input / ui-slash)
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-25-web-input-machine-and-slash-pipeline.md) | 中文
|
||||
|
||||
> 范围:输入状态机(occurrence 表 + claim 看护 + 提交事务)、hub/facade 与发送编排、跨插件输入改写的三个 scoped bail 事件、`/` 与 `@` 触发检测与菜单管线(ui-slash)、composer 周边坑位体系。依赖[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md)的 sctx / provide / session-maybe 与 blank 实体模型;命令知识(三型、目录、popup)零涉——那是[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)的领地。
|
||||
|
||||
## 问题
|
||||
|
||||
两个各自为政的 composer:hero(EmptyState,受控链直写 Session)与会话内 InputBar(普通受控 textarea),行为、draft 所有权、发送路径全不一致。要让 `/` 命令、skill 引用、`@` 引用三类触发进入输入面,必须回答:
|
||||
|
||||
- 三类触发如何分层,谁对"命令"有知识、谁零知识;
|
||||
- 输入框如何表达"命令态"——从 draft 文本推导还是显式状态?退格、回车、空格、整行粘贴各是什么语义;
|
||||
- 提交是异步事务(RPC 往返)——晚到结果回灌、会话切换、React concurrent 重放如何防御;
|
||||
- 引用 chip 在纯 textarea 上如何表示,undo/剪贴板/粘贴匹配/模型序列化各归谁;
|
||||
- 跨插件的输入改写(菜单回填、引用插入、token 消费)如何做到依赖倒置;
|
||||
- 无 session → blank session 时哪些 React 外壳必须复用,哪些严格 session 输入体允许替换。
|
||||
|
||||
硬约束:组件一律经 slots 挂载;呈现物不进 session log;键盘路径全程 IME 安全。
|
||||
|
||||
## 决策
|
||||
|
||||
### 输入状态机(`InputMachine`)
|
||||
|
||||
纯状态机,事件进/效果出,注入时钟。四相 phase(plain / adjudicating / claimed / submitting)。命令态**永不从 draft 推导**,由 pick 路径在离散时刻显式建立;claim 由 `draft.startsWith(token)` 看护、退格破坏自动 release;claim 形状 `{token, hint?}`(hint 供 ghost text)。
|
||||
|
||||
事件面(`dispatch(ev)` 单写入口,每个事件一个 transaction):
|
||||
|
||||
- `draft-changed {draft, editRange?}`——textarea 全量草稿;editRange 缩小 occurrence 平移计算,缺省前后缀共扫。
|
||||
- `newline {selection}`——Ctrl+Enter 换行(不经浏览器 execCommand:自管 undo 下浏览器写入会分叉双历史)。
|
||||
- `begin-command {claim, span}` / `insert-ref {reference, span}` / `consume-token {guard}`——三个 bail 事件的机器侧;span CAS = draftRev 相等。
|
||||
- `set-invalid {invalidIds}`——owner resolution 结果的样式位(非 transaction)。
|
||||
- `undo` / `redo`——自管 transaction log(环形 100;单字符打字按注入时钟窗合并;提交成功清 log)。
|
||||
- `paste-begin {text, selection, components?, generation?}`——粘贴 + 热快照同步匹配组件同 transaction(Undo 一次回粘贴前);打开 PasteMatchAttempt。
|
||||
- `paste-upgrade {attemptId, span, reference}`——异步匹配升级为独立 transaction(Undo 两段);attempt 保持 current,insertedRange 随升级收缩。
|
||||
- `invalidate-paste`——DOM 层观察到的 attempt 终结手势(caret/selection 操作等)。
|
||||
- `enter {mode}` / `adjudicated` / `adjudication-failed` / `submit-settled` / `release`——提交事务平面:SubmitAttempt(seq + AbortSignal)防回灌,成功 commit 清稿,失败带漂移守卫 rollback(回车时快照仅当 live draft 仍等于它才回填;用户已再输入则只发 notice)。
|
||||
|
||||
效果面(shell 执行):`adjudicate`(调 SlashController.adjudicate)、`begin-submit`(claim.submit 事务)、`default-sink`(普通消息,hub 编排)、`notice`。
|
||||
|
||||
occurrence 表与 chip 三投影:
|
||||
|
||||
- 每颗引用在 draft 中占一个 `U+FFFC`;表项 `{occurrenceId, source, ref, offset, label, clipboardText, invalid?}`;同名 chip 因 occurrenceId 独立。
|
||||
- 一切编辑同 transaction 更新 draft 与表:区间平移;与占位符相交的删除/替换作用于整颗。
|
||||
- 单字符占位使键盘原子性大半原生成立(caret 无内部位;Backspace/方向键/Shift 扩选原生即整颗);鼠标点 chip 由 backdrop 命中 → 整颗 setSelectionRange。
|
||||
- 视觉投影 = label:backdrop 在占位符 offset 渲染 chip(textarea 字形不可见),invalid 走失效样式。
|
||||
- 剪贴板/持久化投影 = clipboardText:copy/cut 把选区内占位符展开;draft 持久化 mirror 写同一投影(chat store 里永远是普通文本,刷新 seed 语义 = 全选复制→重开→粘贴,chip 跨刷新降级为文本)。
|
||||
- 模型投影 = submit 时经 source `codec.serialize` 逐颗生成(归 submit attempt 的 signal 与 stale guard;owner 缺失/失败/取消则不发送,不降级为 `/name`)。
|
||||
|
||||
### 跨插件输入改写:三个 scoped bail 事件
|
||||
|
||||
契约声明在 ui-slash(依赖最底层),生产者经 `sctx.bail(sctx, ...)` 派发,唯一消费侧是 hub 建 shell 时挂在 sctx 上的三个 listener;返回 `true` ⟺ 机器过 phase + CAS 守卫并实际改写(发出事件 ≠ 修改成功,Space 是否 `preventDefault` 以返回值为准):
|
||||
|
||||
- `slash/input-begin-command` `{claim, span}`——菜单 pick / Space 裁决出的命令 claim 回填(SlashController 派发)。
|
||||
- `slash/input-insert-reference` `{reference, span}`——引用 chip 插入(SlashController 派发)。
|
||||
- `slash/input-consume-token` `{guard: span | bare-token}`——业务成功后消费命令 token(下游命令面派发)。
|
||||
|
||||
不事件化的调用(registry 注册 → 显式调用 → await):Input 自身的 draft/submit、Enter 异步裁决、reference serializer、异步 paste matcher。`@mode bail` 已入 JSDoc parser 与 cordis catalog 门禁(scripts/jsdoc.ts)。
|
||||
|
||||
### slash 管线(ui-slash:root `SlashService` + per-session `SlashController`)
|
||||
|
||||
对"命令"零知识的触发/菜单/pick 管线:
|
||||
|
||||
- service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一、注册序 = 组序 = 轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按注册序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。
|
||||
- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)、pick 编排(outcome → 自派 bail 事件);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
|
||||
- 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。
|
||||
|
||||
### hub / facade:常驻外壳与严格 session 输入体
|
||||
|
||||
- hub(trigger/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 时外壳渲染纯展示的 `DisabledInputBar`;`connectWorkspace` 返回 blank session 后,仅输入体换成严格 session 的 InputBar。这里允许 textarea 重建,`ConversationRoot`、Hero 与布局骨架保持;blank → engaging/active 仍是同一 session-bound InputBar,textarea 不因 phase 翻转而重建。
|
||||
- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。
|
||||
- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt {mode:'queue'|'steer'}`;失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
|
||||
- blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。
|
||||
- Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。
|
||||
|
||||
### 纯文本引用(决策 21):text outcome 与 lexicon 装饰
|
||||
|
||||
skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draft,chip 视觉纯派生:
|
||||
|
||||
- PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同契约:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
|
||||
- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);controller 聚合为 `lexicon()` 公面。
|
||||
- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即 `.textRef` mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失。
|
||||
- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token)。
|
||||
- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。已知局限维持现状:粘贴/冷启动时 lexicon 未热不装饰,输 `/` 开一次菜单后才亮。
|
||||
|
||||
### per-session 供数贡献与键盘私面
|
||||
|
||||
- ui-conversation(hub 兼贡献者)经 `sessions.provide` 供 `'input'` hook(机器状态 + queue overlay)+ `inputActions` prop(`setDraft`/`submit`,稳定 void 回调)。
|
||||
- 公私分界:公共 provide 只放 React 语汇成员;键盘/DOM 命令面(track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror——同步返回值、disposer 语义)是 InputBar 独占,走 InputBar entry 自己的 inject 包内私递,不出插件边界。
|
||||
|
||||
### 坑位体系
|
||||
|
||||
`conversation` 本身是 session-maybe;其会话内容与 composer 输入坑位严格 session,Hero Workspace picker 保持 root。子坑均由 ui-conversation 的 conversation 注册声明:
|
||||
|
||||
- `conversation.session`(single)——严格 session 的 header、view ring 与 chat store;session id 切换时重建。
|
||||
- `conversation.composer.bar`(single)——InputBar 本体的坑位:InputBar 是真 slot entry(自家坑自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。
|
||||
- `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自 per-session controller。
|
||||
- `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。
|
||||
- `conversation.composer.dock`——composer 上沿统计带。
|
||||
- `conversation.input.left` / `conversation.input.right`——工具行左右区。
|
||||
- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。
|
||||
- `conversation.hero.workspace`(root scope)——无 session / blank Hero 共用的 Workspace picker;pick 经 `connectWorkspace` 复用或创建目标 blank session,必要时搬运 draft 后切 current。
|
||||
|
||||
### 测试纪律
|
||||
|
||||
状态机全部行为由纯 JS 单测覆盖(事件序列进、断言状态与效果,零浏览器 DOM);交互矩阵逐行投影测试。这一要求正是纯核 + 服务壳分层的成因。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| 弃案 | 一行理由 |
|
||||
|---|---|
|
||||
| ActiveCommand 中间态 / registerMode 模式注册表 / 从 draft 推导命令态 | claim 由 pick 路径显式建立——无表、无推导 |
|
||||
| bindTarget/bindDraft 对象直连 | 反向耦合 + root 单例跨会话误配;scoped bail 事件保依赖倒置且路由结构性正确 |
|
||||
| 统一 slash/input-apply 或全事件化 | 三个独立 payload 覆盖跨插件改写;异步链路保持 registry 显式调用 |
|
||||
| contenteditable / 富文本树 | 兼容性差;textarea + U+FFFC + occurrence 表覆盖全部交互契约 |
|
||||
| draft 双持久化 {text, occurrences} | mirror 写剪贴板投影零新概念;chip 跨刷新降级可接受 |
|
||||
| 原生 textarea undo 栈 | 受控 + 程序化写入下不可靠;粘贴两段 undo 语义只能自管 |
|
||||
| InputBar 收 16 员 wiring 回调包 | 消费矩阵实证 11 员 InputBar 独占、1 员死成员;标准件通道让组件自取,键盘面包内私递 |
|
||||
| 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 |
|
||||
| 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 |
|
||||
| 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 |
|
||||
| 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 |
|
||||
|
||||
## 后果
|
||||
|
||||
- 一个常驻 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`)随最后消费者一并删除。
|
||||
- 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;`@` 引用与 skill 引用免费复用同一菜单/pick 管线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结契约。
|
||||
- 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。
|
||||
- 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-29-todo-write-tool.md: df1bee2801b0e01b290b63f6edbe2e5b1be80cb7
|
||||
2026-06-29-todo-write-tool.zh.md: 7fa5cb2aad2b32ef0662df04ff6576be14a3a8e7
|
||||
2026-06-29-todo-write-tool.md: 760373d64f462e3717a174d5793e6d47ab76b0b4
|
||||
2026-06-29-todo-write-tool.zh.md: fd29e6049e6a729c2c7e78860ad7c065422da60e
|
||||
@@ -10,7 +10,7 @@ The harness gives the model bash and subagent tools but no way to record a struc
|
||||
|
||||
## Decision
|
||||
|
||||
Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Interactive hosts render from the durable event; the TUI folds it directly, while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation.
|
||||
Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Interactive hosts render from the durable event: the TUI folds it directly, the web client projects it into `ConversationSnapshot.todos` ([web todo display](2026-07-23-web-todo-display.md)), while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation.
|
||||
|
||||
### Whole-list replace, three-state status
|
||||
|
||||
@@ -18,7 +18,7 @@ The model sends the entire list every call; the new list replaces the old (last-
|
||||
|
||||
### State on the session log, not a service
|
||||
|
||||
The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the current list from the latest `todo/write`, with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that.
|
||||
The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the current list from the latest `todo/write`, with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that. (Full-log consumers get this reconstruction outright; the web client's paged window gets it from the tail history page's host-computed projection — see the [web todo display note](2026-07-23-web-todo-display.md).)
|
||||
|
||||
### NOT a surface event
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结
|
||||
|
||||
## 决策
|
||||
|
||||
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。交互式宿主从持久事件渲染;TUI 直接折叠它,而[仅面向自动化的 ACP(Agent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意省略 todo 展示。
|
||||
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。交互式宿主从持久事件渲染:TUI 直接折叠它,web 客户端将其投影进 `ConversationSnapshot.todos`([web todo 展示](2026-07-23-web-todo-display.md)),而[仅面向自动化的 ACP(Agent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意省略 todo 展示。
|
||||
|
||||
### 整列表替换,三态 status
|
||||
|
||||
@@ -18,7 +18,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结
|
||||
|
||||
### 状态在会话日志上,而非服务
|
||||
|
||||
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从最新的 `todo/write` 重新推导当前列表,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。
|
||||
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从最新的 `todo/write` 重新推导当前列表,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。(全量 log 消费者直接获得这份重建;web 客户端的分页窗口则从尾页 history 携带的 host 计算投影获得——见 [web todo 展示 Note](2026-07-23-web-todo-display.md)。)
|
||||
|
||||
### 不是 surface 事件
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-16-persistent-pty-sessions.md: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95
|
||||
2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
|
||||
2026-07-16-persistent-pty-sessions.md: 43c87bb159cfe1ab9f8d3a80c2adf25a57ae6e3b
|
||||
2026-07-16-persistent-pty-sessions.zh.md: 8afc2103447cc58b1fcbc1062b9564e8ed643477
|
||||
@@ -154,7 +154,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
|
||||
|
||||
- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
|
||||
- Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
|
||||
- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
|
||||
- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
|
||||
- A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation.
|
||||
- Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface.
|
||||
- The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification.
|
||||
|
||||
@@ -154,7 +154,7 @@ plugins:
|
||||
|
||||
- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
|
||||
- Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
|
||||
- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 下的前台 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。
|
||||
- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。
|
||||
- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。
|
||||
- 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。
|
||||
- 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.md: aac67ffec89606d04d5abfd233d0469e2241b102
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0f64e2ce14b18d315f75913b82c731e77758e377
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.md: 8d7c7b00c8d9b15ea3f2419ed44ca88209e60dad
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 49e9541c9f98c9f3beba11945ff452fc38bd9ede
|
||||
+3
-3
@@ -22,9 +22,9 @@ The selected front door receives the exact generated or resumed `SessionId` used
|
||||
|
||||
The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle.
|
||||
|
||||
Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services.
|
||||
Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services.
|
||||
|
||||
The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model pair per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local.
|
||||
The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local.
|
||||
|
||||
### Terminal ownership
|
||||
|
||||
@@ -49,4 +49,4 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t
|
||||
- The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol.
|
||||
- Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor.
|
||||
- Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI.
|
||||
- Model selection uses adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state.
|
||||
- Model and reasoning-effort selection use adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state.
|
||||
+3
-3
@@ -22,9 +22,9 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README
|
||||
|
||||
TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。
|
||||
|
||||
agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。
|
||||
agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。
|
||||
|
||||
`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个 step 快照一次同一个提供方/模型字段组合,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。
|
||||
`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。
|
||||
|
||||
### 终端所有权
|
||||
|
||||
@@ -49,4 +49,4 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调
|
||||
- TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。
|
||||
- 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。
|
||||
- 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。
|
||||
- 模型选择使用适配器提供的目录元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。
|
||||
- 模型和推理强度选择使用适配器公布的元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-web-assistant-markdown.md: ce98a16fa43e2743c18826ee7f2344c38e7c70e7
|
||||
2026-07-23-web-assistant-markdown.zh.md: 0d6fd2f9e6b91f76830586ecf4c29774e5d6978a
|
||||
2026-07-23-web-assistant-markdown.md: 38d193271d88b3a8f32ba1b191e8a6d432176281
|
||||
2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de
|
||||
@@ -12,13 +12,17 @@ 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.
|
||||
|
||||
`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 `dangerouslySetInnerHTML`, raw-HTML parsing, or syntax highlighting. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser is part of the initial browser bundle.
|
||||
`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.
|
||||
|
||||
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 (`复制` / `复制成功`). Citation pills, KaTeX, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers are out of scope until matching product DOM exists; 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.
|
||||
|
||||
## Untrusted output policy
|
||||
|
||||
Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline.
|
||||
Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML).
|
||||
|
||||
The renderer uses existing `--dsw-*` typography and color tokens. Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column.
|
||||
Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,6 +34,8 @@ The renderer uses existing `--dsw-*` typography and color tokens. Fenced code an
|
||||
|
||||
**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies.
|
||||
|
||||
**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.
|
||||
|
||||
## 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. The initial Web shell grows by the Markdown parser and GFM runtime, and future extensions such as syntax highlighting or remote media require a separate bundle and security decision.
|
||||
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, and shiki allowlist; cite/math/anchor/thinking-small surfaces remain deferred.
|
||||
@@ -12,13 +12,17 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
|
||||
|
||||
`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
|
||||
|
||||
`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它支持 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,但不使用 `dangerouslySetInnerHTML`,不解析原始 HTML,也不进行语法高亮。`ui-primitives` 显式声明该依赖;由于这一纯库由 Web shell 预置,解析器会成为初始浏览器 bundle 的一部分。
|
||||
`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `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` 提供语言横幅与复制控件(`复制` / `复制成功`)。引用胶囊、KaTeX、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记均不在范围内,直至存在匹配的产品 DOM;GFM 任务列表继续使用原生复选框。
|
||||
|
||||
该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。
|
||||
|
||||
## 不受信任输出策略
|
||||
|
||||
assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。
|
||||
assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。
|
||||
|
||||
渲染器使用现有的 `--dsw-*` 排版与颜色 token。围栏代码块与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。
|
||||
围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -30,6 +34,8 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S
|
||||
|
||||
**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。
|
||||
|
||||
**移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。
|
||||
|
||||
## 后果
|
||||
|
||||
assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。初始 Web shell 的体积会因加入 Markdown 解析器与 GFM 运行时而增大;语法高亮或远程媒体等后续扩展需要另行作出 bundle 与安全决策。
|
||||
assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时与 shiki 允许列表;cite/math/anchor/thinking-small 表层仍暂缓。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md
|
||||
2026-07-23-web-todo-display.md: 830f55c86c893c4942a1a9d3b8529395d5f5e38b
|
||||
2026-07-23-web-todo-display.zh.md: e68928d7eddaaa92ac831722a738ee2002342b38
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Web todo display — snapshot side-effect channel + two render surfaces
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-web-todo-display.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`todo_write` appends `todo/write` whole-list snapshots to the session log; the TUI renders a persistent plan panel (the automation-only ACP bridge deliberately omits todo presentation). The web client dropped the event entirely: the host mux stream already forwards every session event, but `todo/write` is not a surface type (it never folds into `ConversationSnapshot.nodes`), and no side-effect branch accumulated it — the browser had no consumption point and no display surface.
|
||||
|
||||
## Decision
|
||||
|
||||
Consume `todo/write` as a Session side effect, not a surface node, and render it on two surfaces matching the split the TUI already draws.
|
||||
|
||||
### Side-effect channel, converging with window replay
|
||||
|
||||
`applyEventSideEffects` gains a `todo/write` case (whole list, last write wins). Unlike partial/openCalls, `rebuildDerivedFromWindow` deliberately does NOT reset it: the value is session-level — taken from the tail history page's full-log projection — and an arbitrary window may not contain the latest write, so an older-page prepend keeps it and only an in-window or live write overwrites it. Every `installWindow` caller is a tail request (`doOpen`, its gap re-pull, `repairGap`; `loadOlder` prepends without it), which the host answers with the projection or omits it only when the full log holds no `todo/write` — so an absent field is the authoritative empty list and is assigned as such. That distinction matters on rollback: a live write whose host crashed before persisting leaves the log empty, and preserving the prior value instead would strand the rolled-back plan on screen indefinitely. `ConversationSnapshot.todos` is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing.
|
||||
|
||||
### 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 with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. 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
|
||||
|
||||
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.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Fold todo writes into `nodes` as surface entries** — replayed windows would render every superseded list; the event is deliberately not a surface type.
|
||||
- **Hardcoding the panel inside `ConversationRoot`** — the original landing spot before the input-dock slot existed; the dock is the architecture's home for always-on strips above the composer, and a hardcode bypasses the slot registry's disposal and ordering.
|
||||
- **Details column for the panel** — the details slot is single-occupant and selection-driven, a different lifetime than an always-on strip.
|
||||
- **Host-computed view (a todo `ToolEventView`)** — presentation belongs to the client; the wire already carries the whole snapshot in the event payload.
|
||||
|
||||
## Consequences
|
||||
|
||||
Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 65) plus the assembled keyless snapshot (`apps/web/tests/todo-display.snapshot.ts`) pin the full chain (row summary and state, dock panel content, collapse round-trip) over the built client graph. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The TUI panel is untouched (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log latest `todo/write`, computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan even when the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, and resets to empty when a tail response carries no projection.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Web todo 展示——快照副作用通道 + 两个渲染面
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-web-todo-display.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`todo_write` 把 `todo/write` 的整份列表快照追加进会话日志;TUI 渲染一块常驻的 plan 面板(自动化专用的 ACP 桥接刻意不做 todo 呈现)。Web 客户端把这个事件整个丢弃了:host mux 流本已转发每一个会话事件,但 `todo/write` 不是 surface 类型(它从不 fold 进 `ConversationSnapshot.nodes`),也没有任何副作用分支累积它——浏览器既无消费点,也无展示面。
|
||||
|
||||
## Decision
|
||||
|
||||
把 `todo/write` 当作 Session 副作用消费,而非 surface 节点,并在两个面上渲染它,这两个面正对应 TUI 已经绘制的那套划分。
|
||||
|
||||
### 副作用通道,与窗口回放收敛
|
||||
|
||||
`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写)。与 partial/openCalls 不同,`rebuildDerivedFromWindow` 刻意不重置它:该值是会话级的——取自尾页 history 携带的全量 log 投影——而任意窗口未必包含最近一次写入,因此往前翻页保留它,只有窗口内或实时的写入才会覆盖。`installWindow` 的每个调用方都是尾页请求(`doOpen`、其补洞重拉、`repairGap`;`loadOlder` 只往前拼接、不走它),而 host 对尾页请求要么带上投影、要么仅在全量 log 没有任何 `todo/write` 时省略——因此字段缺失就是权威的空列表,直接照此赋值。这个区分在回滚场景上要紧:实时写入若在 host 持久化前崩溃,log 里就是空的,此时保留旧值会让已回滚的计划永远留在屏幕上。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。
|
||||
|
||||
### TodoPanel:长驻列表作为一条常驻横条
|
||||
|
||||
面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。
|
||||
|
||||
### TodoRow:经 keyed toolview slot 的逐调用行
|
||||
|
||||
专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.register` 注册进 keyed 的 `conversation.chat.toolview` slot——与 bash 样例同一接缝、同一载序姿态(`inject: ['slots', 'conversation']`),但属产品级注册。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **把 todo 写入作为 surface 条目折叠进 `nodes`**——回放的窗口会渲染每一份已被取代的列表;该事件被刻意设计成非 surface 类型。
|
||||
- **面板硬编码进 `ConversationRoot`**——input-dock slot 出现之前的原始落点;dock 是本架构给"composer 上方常开横条"安排的家,硬编码绕开了 slot 注册表的 disposal 与定序。
|
||||
- **面板放进 details 列**——details slot 单占用且由选中驱动,生命周期不同于一条常开横条。
|
||||
- **host 计算的视图(一个 todo `ToolEventView`)**——呈现属于客户端;协议已在事件载荷里携带整份快照。
|
||||
|
||||
## Consequences
|
||||
|
||||
回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 65 轮的 fixture(测试前置数据)加 assembled keyless snapshot(`apps/web/tests/todo-display.snapshot.ts`)在构建产物客户端全图上钉住整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板未受改动(自动化专用的 ACP 桥接刻意不做 todo 呈现);Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。冷加载重建正是靠这个字段由 host 兜底:history 尾页附带 `todos`——全量 log 上最新一次 `todo/write` 的投影,独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时即使最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,而尾页响应不带投影时复位为空。
|
||||
@@ -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
|
||||
2026-07-27-user-message-icon-actions.md: 869e7a2518a3ec927c0689a10816a410dc5f0862
|
||||
2026-07-27-user-message-icon-actions.zh.md: 353e5ac765bb2fbab9932449cf2247768a1f412f
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: User-message IconActions under the bubble
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-user-message-icon-actions.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The chat user bubble had no under-bubble action chrome. The Harness design (figma `User_Bubble/message_container`) shows three IconActions — copy, branch in new chat, and edit — right-aligned under the bubble, matching the product action-bar pattern used elsewhere.
|
||||
|
||||
## Decision
|
||||
|
||||
`MessageItem` owns the actions for `kind: 'user'` only. Layout is a column (`align-items: flex-end`, 6px gap): bubble, then a 28px action row with 10px gaps and 28px circular icon buttons (`IconCopyOutline16`, `IconBranchOutline16`, `IconEditOutline16`). Tooltips carry Chinese labels. Actions stay visible by default; `@media (hover: hover)` hides them until the row is hovered or focus-within, so touch / `hover: none` devices keep discoverable controls (opacity alone still hit-tests).
|
||||
|
||||
Copy writes the bubble's joined text blocks to the clipboard (`navigator.clipboard.writeText`, with an `execCommand` fallback). Branch and edit are present chrome with no handlers yet — they reserve the design seats without inventing session-fork or edit-resubmit behavior.
|
||||
|
||||
Steering bubbles keep the badge-only form and do not show these actions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Wire branch/edit to real session fork and draft-edit now.** Rejected for this change: those product flows are not specified; shipping inert buttons matches the requested scope and avoids half-built mutation paths.
|
||||
|
||||
**Always hide with `opacity: 0` outside hover.** Rejected for touch: without `@media (hover: hover)`, idle opacity still hit-tests while looking empty. Hover-capable pointers keep the fade; others keep the actions visible.
|
||||
|
||||
## Consequences
|
||||
|
||||
User messages expose copy immediately; branch/edit remain clickable stubs until a later decision owns their behavior. Tests pin the three buttons, copy payload, and steering exclusion.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: 用户消息气泡下方的 IconActions
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-user-message-icon-actions.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
聊天用户气泡下方没有操作栏。Harness 设计稿(figma `User_Bubble/message_container`)在气泡下方右对齐展示三个 IconActions——复制、在新对话中分支、编辑——与产品其他位置使用的操作栏模式一致。
|
||||
|
||||
## 决策
|
||||
|
||||
仅当 `kind: 'user'` 时,`MessageItem` 拥有这些操作。布局为纵向列(`align-items: flex-end`,间距 6px):先是气泡,再是高度 28px 的操作行;行内间距 10px,圆形图标按钮尺寸为 28px(`IconCopyOutline16`、`IconBranchOutline16`、`IconEditOutline16`)。Tooltip 承载中文标签。操作默认保持可见;`@media (hover: hover)` 下在悬停或 focus-within 前隐藏,以便触摸/`hover: none` 设备仍能发现控件(仅靠 opacity 仍会命中测试)。
|
||||
|
||||
复制将气泡内拼接后的文本块写入剪贴板(`navigator.clipboard.writeText`,并以 `execCommand` 作为回退)。分支与编辑目前仅有外观、尚无处理函数——它们预留设计席位,但不发明会话 fork 或编辑重提交流程。
|
||||
|
||||
steering(中途引导)气泡保持仅徽章形态,不展示这些操作。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**现在就把分支/编辑接到真实的会话 fork 与草稿编辑。**本次变更不予采纳:这些产品流程尚未定稿;交付无行为按钮符合请求范围,也避免半成品的变更路径。
|
||||
|
||||
**在悬停外始终以 `opacity: 0` 隐藏。**因触摸不予采纳:若无 `@media (hover: hover)`,空闲 opacity 看起来空白但仍会命中测试。具备悬停能力的指针保留淡入;其他设备保持操作可见。
|
||||
|
||||
## 后果
|
||||
|
||||
用户消息立即可用复制;分支/编辑仍为可点击的占位,直至后续决策明确其行为。测试钉死三个按钮、复制载荷,以及对 steering 的排除。
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.md: bebff600ca27763c04bdecea78ceb916542f7eca
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 66c9b75b1bab47558bb63b7e97cf6d7c7610b0d5
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.md: 8e9d0ab8653528517346b198c97814d8d8979440
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.zh.md: cf37d56be0180cc742b0dabefeb1f8f3b54ba557
|
||||
@@ -11,7 +11,7 @@ This repo's documentation corpus is read by people and agents inside and outside
|
||||
## Decision
|
||||
|
||||
- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).
|
||||
- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.
|
||||
- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write <pair>`, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.
|
||||
- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.
|
||||
- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.
|
||||
- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.
|
||||
@@ -41,4 +41,4 @@ Paired sibling files with locale suffixes are the dominant Chinese big-tech conv
|
||||
- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.
|
||||
- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.
|
||||
- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.
|
||||
- The recorded hashes double as the update tool (`git cat-file -p <hash>` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism.
|
||||
- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.
|
||||
@@ -11,7 +11,7 @@ Status: implemented
|
||||
## 决策
|
||||
|
||||
- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。
|
||||
- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。
|
||||
- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write <pair>`,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。
|
||||
- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。
|
||||
- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。
|
||||
- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。
|
||||
@@ -41,4 +41,4 @@ Status: implemented
|
||||
- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。
|
||||
- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。
|
||||
- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。
|
||||
- 记录的 hash 兼作更新工具(`git cat-file -p <hash>` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。
|
||||
- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md
|
||||
2026-07-26-briefed-minimal-translation-updates.md: 63f25d5c36caecba435e9192534e0b494e1e0105
|
||||
2026-07-26-briefed-minimal-translation-updates.zh.md: 17cf2f895b187fb794e691c2b14d6e0bff78363d
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: Briefed minimal translation updates
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-26-briefed-minimal-translation-updates.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [bilingual pairing contract](2026-07-02-bilingual-docs-and-pairing-gate.md) already prescribed minimal counterpart updates — diff the edited side against its last-confirmed state, patch the counterpart, never re-translate — but the committed workflow made every update pay whole-document overheads. The translating subagent loaded the full guidance corpus (skill, pairing contract, translation rules, the 192-line terminology table, style samples, prose standard) before touching a two-line diff; it re-derived the last-confirmed diff by hand through `git cat-file`; and each iteration re-ran the corpus-wide pairing gate, which parses every pair in the tree to validate one. A small English prose edit routinely cost tens of times its proportional share of tokens and minutes, which taxes exactly the behavior the contract wants — bringing the counterpart along in the same PR.
|
||||
|
||||
## Decision
|
||||
|
||||
Pair updates run on a generated briefing instead of the guidance corpus; only new pairs still run the whole-document workflow, which is unchanged.
|
||||
|
||||
- **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the [incremental prompt-pipeline work](https://github.com/deepseek-harness/deepseek-harness/pull/684), whose provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer.
|
||||
- **The update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document.
|
||||
- **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command.
|
||||
|
||||
## Benchmark
|
||||
|
||||
The decision followed a controlled replay of ten real pair updates from this repo's history (July 2026; 1-64 changed English lines each, READMEs, RFCs, Agent Notes, and user docs). Each example was reconstructed in a scratch repo at its true last-confirmed state with the English edit uncommitted, then run through competing workflows with fresh subagents: the status-quo corpus-loading path, the briefed path, a no-guidance control, whole-document re-translation, the briefed path on a small model, and a three-pairs-per-agent batch. Outputs were gated mechanically and scored blind by judges who also received the real historical update and the untouched stale counterpart as controls.
|
||||
|
||||
- The briefed path matched the status-quo path on judged faithfulness, preservation, and fluency — both at or above the real historical updates — while spending roughly a third of the tokens and wall clock on the stall-free examples (medians across all ten: 276k vs 595k relative token-cost units, 14 vs 32 turns).
|
||||
- Re-translation was confirmed harmful, not merely wasteful: judged preservation collapsed (4.4/10 vs 9.8) because it discards reviewed phrasing, it drifted established terminology the update arms kept (the counterpart's own text carries the renderings), and it was the most expensive arm.
|
||||
- The no-guidance control held quality too — the binding context for an update is the diff plus the counterpart's own reviewed text, not the corpus — but the briefing buys a fixed working set, inline terminology, and the both-sides-drifted warning at negligible cost over it.
|
||||
- On the briefing, a small model performed at parity with the large one, so the update path no longer assumes a frontier translator.
|
||||
- Batching three pairs into one subagent showed no reliable saving over three briefed runs and couples unrelated failures; it was rejected.
|
||||
|
||||
A second head-to-head replay on the same ten examples compared this note's shipped briefing against its earlier section-only form (no unit tier, no computed mechanical path, counterpart-only context, no first-occurrence tracking). Prose quality and cost were at parity — pairwise blind verdicts split with only stylistic margins — and the shipped form won on two objective outcomes: the two code-fence-only examples were completed byte-identical to the human-reviewed historical updates in under a second with no model tokens, and on the example whose edit moved a term's document-wide first occurrence, the shipped briefing's flagged move reproduced the human-reviewed gloss relocation while the section-only form left a 首次出现 violation for review to catch.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the workflow, just scope the gate** — the gate scan was the smaller cost; the corpus loads and archaeology dominated. Scoping alone would have left the ~3x overhead in place.
|
||||
- **Whole-document re-translation as the update path** (what a naive pipeline does) — rejected on benchmark evidence: preservation collapse, terminology drift, highest cost. The contract's minimal-update rule survives with data behind it.
|
||||
- **Batching several pairs per subagent** — rejected: no measured saving (briefings already deduplicate the fixed content), and one stalled or confused pair holds the others hostage.
|
||||
- **Per-paragraph translation-memory records in the sidecar** (segment hashes instead of whole-file hashes) — rejected: paragraph boundaries may legitimately differ across the pair, either side can be authored first, and the records would bloat and conflict in merges. Span mapping computed on demand from the existing whole-file hashes recovers the same alignment when it is trustworthy and says so when it is not.
|
||||
- **An update mode in the automated prompt pipeline (prompt-v5)** — deferred, not designed here: nothing drives [scripts/translation-prompt.ts](../../../../scripts/translation-prompt.ts) today, and the agent path was the live cost center. The pipeline keeps its whole-document v4 contract until it has a consumer.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A small prose edit's counterpart update now costs a briefing generation plus one small focused task — no corpus reads, no archaeology, no corpus-wide scans inside the loop — and the same PR obligation holds; the cheap path and the correct path point the same way.
|
||||
- The briefing generator is a second consumer of the consistency records: recorded blob hashes now also drive diff recovery and section mapping, strengthening the incentive to keep records honest.
|
||||
- `--write` without arguments no longer works; muscle-memory callers must name pairs or pass `--all`. That is the point — the bulk bless is now a visible, deliberate act.
|
||||
- Scoped checks mean an update loop can be green while an unrelated pair elsewhere is red; the corpus-wide check in `doc-sync`/CI still owns the tree-level invariant.
|
||||
- Span mapping trusts an alignment only when the kind sequences match across the last-confirmed source, current source, and current counterpart; a mapping failure widens deterministically (units → sections → whole document) rather than guessing, so a restructured document gets an explicit "locate the regions yourself" briefing, never a wrong map.
|
||||
- A first-occurrence move can enlarge a briefing beyond the directly changed spans; that cost is an explicit consequence of the 首次出现 contract, not an alignment heuristic.
|
||||
|
||||
## Testing
|
||||
|
||||
[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) pins unit and section span extraction (container-scoped kinds, depth-only section alignment so translated heading text still maps, preamble), alignment and changed-index detection, the mechanical code splice and each of its refusal conditions, terminology row matching in both directions with word-boundary and plural-inflection discipline, first-occurrence movement tracking, fence escalation, and the rendered briefing's contract (unit bundles with three-way context, mechanical/sections/document scopes, per-direction digests, scoped finish commands). [scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) pins argument normalization (any pair file or bare stem to the anchor) and the CLI matrix: scoped check, bare `--write` refusal, `--write <pair>`, `--write --all`, `--list` exclusivity, unknown flags.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Agent Note: 基于简报的最小化翻译更新
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-26-briefed-minimal-translation-updates.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[双语配对契约](2026-07-02-bilingual-docs-and-pairing-gate.md)早已规定对侧文件按最小幅度更新:把被改的一侧与其上次确认状态做 diff,据此修补对侧文件,绝不整篇重译;但仓库内置的工作流让每次更新都付出整篇文档级别的开销。负责翻译的 subagent 在动手处理一个两行的 diff 之前,要先加载完整的指导语料(guidance corpus),即 skill(技能)、配对契约、翻译规则、192 行的术语表、语体样例与行文标准;要通过 `git cat-file` 手工重新推导上次确认状态以来的 diff;每轮迭代还要重跑全语料配对门禁,而该门禁为校验一个配对要解析整棵树里的每一个配对。一次小的英文行文修改,动辄花掉数十倍于其应得份额的 token 用量与分钟数,被惩罚的恰恰是契约想要的行为:在同一个 PR(Pull Request)里把对侧文件一并带上。
|
||||
|
||||
## 决策
|
||||
|
||||
配对更新基于生成的简报(briefing)运行,而非基于指导语料;只有新建配对仍走整篇文档工作流,后者保持不变。
|
||||
|
||||
- **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了[增量提示词流水线工作](https://github.com/deepseek-harness/deepseek-harness/pull/684)中的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。
|
||||
- **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。
|
||||
- **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。
|
||||
|
||||
## 基准测试
|
||||
|
||||
该决策来自对本仓库历史上十次真实配对更新的受控回放(2026 年 7 月;每例改动 1 到 64 行英文,涵盖 README、RFC、Agent Note(agent 决策记录)与用户文档)。每个样例都在临时仓库中重建到其真实的上次确认状态,英文改动保持未提交,再用全新的 subagent 分别跑过相互竞争的各条工作流:维持现状的语料加载路径、简报路径、无指导对照组、整篇重译、小模型上的简报路径,以及每个 agent(智能体)一次处理三对文档的批量方案。产出先经机械门禁把关,再由评委盲评打分;评委还同时收到真实的历史更新与原样未动的陈旧对侧文件作为对照。
|
||||
|
||||
- 简报路径在盲评的忠实性、保留度与流畅度上与现状路径打平(两者都达到或超过真实历史更新的水平),而在未发生停滞的样例上只花费约三分之一的 token 用量与墙钟时间(全部十例的中位数:相对 token 成本单位 276k 对 595k,轮次数 14 对 32)。
|
||||
- 整篇重译被证实有害,而不只是浪费:它丢弃经评审的措辞,盲评保留度因此崩塌(4.4/10 对 9.8);它还使各更新组保持住的既定术语发生漂移(既定译法本就写在对侧文件自身的正文里);而且它是成本最高的一组。
|
||||
- 无指导对照组的质量同样立得住(对一次更新有约束力的上下文,是 diff 加上对侧文件自身经评审的正文,而非指导语料),但简报以几乎可忽略的额外成本,换来固定的工作集、内联的术语,以及两侧同时漂移的警告。
|
||||
- 以简报为输入,小模型的表现与大模型持平,因此更新路径不再假定翻译必须由前沿模型完成。
|
||||
- 把三对文档合并给同一个 subagent,相比三次各自带简报的运行没有可靠的节省,还把互不相关的失败耦合在一起;该方案被否决。
|
||||
|
||||
在同样这十个样例上进行的第二次正面对比回放,把本文最终交付的简报与其早前仅按章节的形态(没有单元层级、没有直接算出的机械路径、上下文只含对侧文件、不跟踪首次出现)相对照。行文质量与成本两相持平(两两盲评裁定各有胜负,差距仅在文风),而最终交付的形态在两项客观结果上胜出:两个只涉及围栏代码块的样例在一秒之内完成且不消耗任何模型 token,产出与经人工评审的历史更新逐字节一致;而在那个编辑使某术语在整篇文档中的首次出现发生移位的样例上,最终交付的简报所标记的移位复现了经人工评审的括注迁移,仅按章节的形态则留下一处「首次出现」违例,留待评审去捕捉。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **保留原工作流,只让门禁支持按对检查**:门禁扫描本是较小的开销,大头在语料加载与翻查历史。只收窄检查范围,约 3 倍的开销仍会原地保留。
|
||||
- **把整篇重译作为更新路径**(朴素流水线的做法):依据基准测试证据否决,理由是保留度崩塌、术语漂移、成本最高。契约的最小更新规则得以延续,且从此有数据支撑。
|
||||
- **每个 subagent 批量处理多对文档**:否决。没有实测出节省(简报本身已对固定内容做了去重),而且一对文档停滞或陷入混乱会把其余配对一并拖住。
|
||||
- **在伴随记录中保存逐段的翻译记忆条目**(用分段 hash 取代整文件 hash):否决。配对两侧的段落边界可以合理地不同,任一侧都可能先撰写,这类条目还会不断膨胀并在合并时产生冲突。基于现有整文件 hash 按需计算的区间映射,在对齐可信时能恢复同样的对齐关系,不可信时会明确说明。
|
||||
- **给自动提示词流水线加一个更新模式(prompt-v5)**:推迟,本文不做设计。今天没有任何调用方在驱动 [scripts/translation-prompt.ts](../../../../scripts/translation-prompt.ts),实际的成本中心是 agent 路径。流水线在拥有消费方之前,维持其整篇文档的 v4 契约。
|
||||
|
||||
## 后果
|
||||
|
||||
- 一次小的行文修改,其对侧更新如今只需生成一份简报,外加一个小而聚焦的任务(不读指导语料、不翻查历史、循环内不做全语料扫描),同一 PR 内完成更新的义务保持不变;低成本的路径与正确的路径指向同一个方向。
|
||||
- 简报生成器成为一致性记录的第二个消费方:记录的 blob hash 如今还驱动 diff 还原与章节映射,这进一步强化了如实维护记录的动机。
|
||||
- 不带参数的 `--write` 不再可用;靠肌肉记忆的调用者必须点名配对或传 `--all`。这正是目的所在:批量背书如今是一个可见的、有意为之的动作。
|
||||
- 按对检查意味着一个更新循环可以在别处某个无关配对处于红灯时自己保持绿灯;`doc-sync`/CI 中的全语料检查仍然承载树级不变式。
|
||||
- 区间映射只在上次确认源文、当前源文与当前对侧文本三方的种类序列一致时才信任一处对齐;映射失败时粒度确定性地逐级放宽(单元 → 章节 → 整篇文档)而不是靠猜,因此被重构过的文档拿到的是一份明确写着「请自行定位相关区域」的简报,绝不会是一张错误的地图。
|
||||
- 「首次出现」的一次移位可能让简报扩大到直接改动块之外;这一成本是「首次出现」契约的明确后果,而非对齐启发式。
|
||||
|
||||
## 测试
|
||||
|
||||
[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) 固定单元与章节的区间提取(以容器为作用域的种类、只按深度对齐章节从而让已翻译的标题文字仍能映射、首个标题前的序言)、对齐与改动索引检测、机械代码拼接及其每一个拒绝条件、带词边界与复数变形约束的双向术语行匹配、首次出现移位跟踪、围栏升级,以及渲染后简报的契约(带三方上下文的单元条目、机械/章节/整篇文档三种范围、分方向的规则摘要、按对的收尾命令)。[scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) 固定参数归一化(配对的任一文件或裸词干都归一到锚点)与 CLI(命令行界面)用例矩阵:按对检查、裸 `--write` 拒绝执行、`--write <pair>`、`--write --all`、`--list` 的互斥性、未知标志。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-26-eventsource-parser-for-deepseek-sse.md: 8a93b7f6c7aa0d428f25e87c44e1d29e884ecc81
|
||||
2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: b16109d9458f487c7e463cf02e6b2d22fbbde015
|
||||
2026-07-26-eventsource-parser-for-deepseek-sse.md: e7835bc738b3dec5aefd6011848525f6604e852e
|
||||
2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 933b993d479026d8f2bd2dc3173abd9e60823806
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Agent Note: Replace the hand-rolled SSE parser in llm-deepseek with eventsource-parser
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-26-eventsource-parser-for-deepseek-sse.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`packages/llm/llm-deepseek/src/sse.ts` hand-implemented Server-Sent Events parsing: a streaming `TextDecoder`, event-block splitting on `\r?\n\r?\n`, `data:` payload extraction and joining, comment/field skipping, the `[DONE]` sentinel, a `STREAM_CLOSED` error on EOF without it, and a flush of a final unterminated event block. The file was ~67 lines with ~108 lines of dedicated tests (`tests/sse.spec.ts`) re-proving SSE spec behavior — UTF-8 split across chunks, CRLF handling, multi-`data:` joining, no-space-after-colon — that a maintained parser already guarantees. Its only consumer is `adapter.ts` (`yield* translate(parseSse(response.body))`).
|
||||
|
||||
This is exactly the surface `eventsource-parser` owns: the de-facto standard SSE parser (it underlies the Vercel AI SDK and the MCP SDK), zero-dependency, actively maintained, and already present in this repo's lockfile transitively via `@modelcontextprotocol/sdk` — so adopting it directly adds no new supply-chain surface in practice.
|
||||
|
||||
## Decision
|
||||
|
||||
`sse.ts` delegates SSE framing to `EventSourceParserStream` from `eventsource-parser/stream`: `parseSse` pipes the response body through `new TextDecoderStream()` then `new EventSourceParserStream()` and keeps only the DeepSeek protocol shim — yield each event's `data`, terminate on `[DONE]`, and throw `LlmError('STREAM_CLOSED')` when the stream ends without the sentinel. All required builtins (`TextDecoderStream`, `pipeThrough`, async-iterable `ReadableStream`) exist at the Node ^22.19 engine floor. The spec-conformance tests are gone; `tests/sse.spec.ts` pins only the `[DONE]`/`STREAM_CLOSED`/EOF contract. `eventsource-parser` is `llm-deepseek`'s second runtime dependency after schemastery. The [twin-adapters note](../architecture/2026-06-13-twin-llm-adapters.md) and the `dsh-llm` JSDoc that branded this adapter "hand-rolled fetch + SSE parsing" now describe it as direct fetch with library-framed SSE.
|
||||
|
||||
The library also strips a leading BOM (the hand-rolled parser would fail to match `data:` after one) and offers `maxBufferSize` hardening the hand-rolled parser lacked.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the hand-rolled parser.** Defensible under the [twin-adapters decision](../architecture/2026-06-13-twin-llm-adapters.md): the adapter is deliberately the hand-rolled design-verification twin of the pi-ai adapter. But that note's load-bearing distinction is owning the fetch/translate internals versus delegating to a full provider SDK; a ~700-byte SSE micro-parser is transport plumbing, not the design under verification. The twin-adapters note now states that reading explicitly.
|
||||
- **`createParser({onEvent})` callback API instead of the stream.** Works fed by a manual `TextDecoder` loop, but the `pipeThrough` composition deletes more of the hand-rolled code.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The remaining shim only encodes the DeepSeek `[DONE]`/`STREAM_CLOSED` protocol; SSE framing edge cases are eventsource-parser's contract and are no longer re-proven here.
|
||||
- One deliberate robustness deviation is dropped: the hand-rolled parser flushed a final event block that lacked its terminating blank line, so a trailing `data: [DONE]` without `\n\n` still yielded DONE. eventsource-parser is spec-strict and only dispatches on the blank line, so that shape is now `STREAM_CLOSED`. Real providers and `dsh-llm-mock-server` always terminate events properly — the flush was a robustness nicety, not an observed provider shape — and `tests/sse.spec.ts` pins the new truncation verdict for that tail.
|
||||
- The documented "hand-rolled" identity of the twin adapter narrows to the fetch/translate internals; the twin-adapters note was updated in the same change rather than leaving the claim stale.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE 解析器
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-26-eventsource-parser-for-deepseek-sse.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`packages/llm/llm-deepseek/src/sse.ts` 曾手写实现 SSE(Server-Sent Events)解析:一个流式 `TextDecoder`、按 `\r?\n\r?\n` 切分事件块、提取并拼接 `data:` 载荷、跳过注释与其他字段、`[DONE]` 哨兵、在未见哨兵即 EOF 时抛出 `STREAM_CLOSED` 错误,以及对最后一个未终结事件块的 flush。该文件约 67 行,另有约 108 行专属测试(`tests/sse.spec.ts`)重复验证 SSE 规范行为——UTF-8 字符被切分到多个分片、CRLF 处理、多条 `data:` 拼接、冒号后无空格——而这些行为,持续维护的解析器早已有保证。它唯一的消费方是 `adapter.ts`(`yield* translate(parseSse(response.body))`)。
|
||||
|
||||
这恰好是 `eventsource-parser` 负责的接口面:事实标准的 SSE 解析器(Vercel AI SDK 和 MCP SDK 都构建在它之上),零依赖,持续维护,并且已通过 `@modelcontextprotocol/sdk` 作为传递依赖出现在本仓库的 lockfile 中——因此直接采用它实际上不增加新的供应链接触面。
|
||||
|
||||
## 决策
|
||||
|
||||
`sse.ts` 将 SSE 分帧委托给 `eventsource-parser/stream` 的 `EventSourceParserStream`:`parseSse` 把响应 body 依次管道接入 `new TextDecoderStream()` 和 `new EventSourceParserStream()`,只保留 DeepSeek 协议垫层——逐个产出事件的 `data`,遇到 `[DONE]` 终止,流在未见哨兵时结束则抛出 `LlmError('STREAM_CLOSED')`。所需的全部内置能力(`TextDecoderStream`、`pipeThrough`、可异步迭代的 `ReadableStream`)在 Node ^22.19 引擎下限即已存在。规范符合性测试已删除;`tests/sse.spec.ts` 只固定 `[DONE]`/`STREAM_CLOSED`/EOF 契约。`eventsource-parser` 是 `llm-deepseek` 继 schemastery 之后的第二个运行时依赖。曾把该适配器标为「手写 fetch + SSE 解析」的[孪生适配器 Agent Note(agent 决策记录)](../architecture/2026-06-13-twin-llm-adapters.md)与 `dsh-llm` JSDoc,现在将其描述为直接 fetch 加库分帧的 SSE。
|
||||
|
||||
该库还会剥离开头的 BOM(手写解析器在 BOM 之后会无法匹配 `data:`),并提供手写解析器缺少的 `maxBufferSize` 加固能力。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **保留手写解析器。** 依据[孪生适配器决策](../architecture/2026-06-13-twin-llm-adapters.md),这一选择有辩护余地:该适配器有意作为 pi-ai 适配器的手写设计验证孪生体。但那份 Agent Note 起支撑作用的区分在于「自行持有 fetch/translate 内部实现」与「委托给完整的提供方 SDK」;一个约 700 字节的 SSE 微型解析器属于传输层管道,不是被验证的设计本身。孪生适配器 Agent Note 现已明确写出这一解读。
|
||||
- **改用 `createParser({onEvent})` 回调 API 而非流。** 配合手动的 `TextDecoder` 循环可以工作,但 `pipeThrough` 组合方式能删除更多手写代码。
|
||||
|
||||
## 后果
|
||||
|
||||
- 剩下的垫层只编码 DeepSeek 的 `[DONE]`/`STREAM_CLOSED` 协议;SSE 分帧边界情形属于 eventsource-parser 的契约,不再在这里重复验证。
|
||||
- 放弃了一处有意为之的健壮性偏离:手写解析器会 flush 缺少终结空行的最后一个事件块,因此末尾的 `data: [DONE]` 即使没有 `\n\n` 也仍产出 DONE。eventsource-parser 严格遵循规范,只在空行处分发事件,所以这种形态现在是 `STREAM_CLOSED`。真实提供方和 `dsh-llm-mock-server` 总是正确终结事件——该 flush 只是健壮性上的锦上添花,并非实际观测到的提供方形态——`tests/sse.spec.ts` 固定了对该尾部的新截断判定。
|
||||
- 孪生适配器有文档记录的「手写」身份收窄到 fetch/translate 内部实现;孪生适配器 Agent Note 在同一次变更中更新,而不是让声明陈旧下去。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-18-tui-terminal-state-snapshots.md: 8e86588f69fdb9d615232252ecf57309d440f1cd
|
||||
2026-07-18-tui-terminal-state-snapshots.zh.md: b70a46830f44e9da663e30745fcdb7ad281592da
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md
|
||||
2026-07-18-tui-terminal-state-snapshots.md: 18c79bc2d0dabf4d78887354f30a2cdc083899e1
|
||||
2026-07-18-tui-terminal-state-snapshots.zh.md: d1d4a6ca859e94a153e0bf645a17f03c0dac234b
|
||||
@@ -33,7 +33,7 @@ The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their pr
|
||||
|
||||
### Semantic terminal projection
|
||||
|
||||
The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state, so a checkpoint represents a completed screen rather than a timer-dependent write prefix.
|
||||
The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state. The streaming checkpoint freezes the loader interval while allowing real wall-clock delay across one animation tick, so it pins semantic status rather than whichever spinner glyph the scheduler happened to render.
|
||||
|
||||
Each expected output projects dimensions, active-buffer and viewport coordinates, lifecycle and cursor state, rows, wrap markers, and non-default style ranges into text. Scroll-heavy cards capture the used buffer; overlays capture the visible viewport. Text and style remain separate so a reviewer can distinguish content changes from presentation changes without decoding ANSI bytes.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ TUI 覆盖分为四个互补层次:
|
||||
|
||||
### 语义终端投影
|
||||
|
||||
包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀。
|
||||
包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定。流式输出检查点会冻结 loader 的 interval,同时保留跨过一次动画 tick 的真实墙钟等待,从而固定语义状态,而非调度器碰巧渲染出的某个加载动画字形。
|
||||
|
||||
每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: c4e34b3f44162c7021cb25681eea7e49ac78f672
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: 466e1c0fc16aac21b87b68cfedaec4fb22a417e2
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: d9e0a9660ecd6aeb75e835e68f92c0a268423872
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: e8c7d1c4596f20d88bd08423549fb6a9f7b0654b
|
||||
@@ -18,7 +18,7 @@ A plain shared-fixture module (the [testing-policy sanctioned shape](../../../..
|
||||
|
||||
`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure.
|
||||
|
||||
Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelContext` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER.
|
||||
Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER.
|
||||
|
||||
`seedSession()` seeds cold sessions through the real persistence API — a throwaway `Context` mounting `SessionStore` + `SessionPersistenceJsonl` against the host's root, `create()` + `append()`, one `utimes` backdate for deterministic sidebar order (the `semantic-checkpoint.snapshot.ts` precedent) — never raw file writes, so the seeder knows nothing of bucket hashing, filename encoding, or compression, and the host's zstd default needs no boot knob. Seeds are validated at seed time (parseable, ending in `turn/end` — an open final turn would be mutated by resume's crash repair).
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。
|
||||
|
||||
无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelContext` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。
|
||||
无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。
|
||||
|
||||
`seedSession()` 通过真实持久化 API 播种冷会话——一次性 `Context` 挂载 `SessionStore` + `SessionPersistenceJsonl` 指向 host 的根目录,`create()` + `append()`,一次 `utimes` 回拨保证侧栏顺序确定(`semantic-checkpoint.snapshot.ts` 先例)——绝不裸写文件,因此播种器对桶哈希、文件名编码、压缩一无所知,host 的 zstd 默认值也无需任何启动开关。种子在播种时即校验(可解析、以 `turn/end` 结尾——未闭合的最终轮次会被恢复(resume)的崩溃修复改写)。
|
||||
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
# Agent Note: Replace the hand-rolled SSE parser in llm-deepseek with eventsource-parser
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-26-eventsource-parser-for-deepseek-sse.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`packages/llm/llm-deepseek/src/sse.ts` hand-implements Server-Sent Events parsing: a streaming `TextDecoder`, event-block splitting on `\r?\n\r?\n`, `data:` payload extraction and joining, comment/field skipping, the `[DONE]` sentinel, a `STREAM_CLOSED` error on EOF without it, and a flush of a final unterminated event block. The file is ~67 lines with ~108 lines of dedicated tests (`tests/sse.spec.ts`) re-proving SSE spec behavior — UTF-8 split across chunks, CRLF handling, multi-`data:` joining, no-space-after-colon — that a maintained parser already guarantees. Its only consumer is `adapter.ts` (`yield* translate(parseSse(response.body))`).
|
||||
|
||||
This is exactly the surface `eventsource-parser` owns: the de-facto standard SSE parser (it underlies the Vercel AI SDK and the MCP SDK), zero-dependency, actively maintained, and already present in this repo's lockfile transitively via `@modelcontextprotocol/sdk` — so adopting it directly adds no new supply-chain surface in practice.
|
||||
|
||||
## Proposal
|
||||
|
||||
Replace `sse.ts` with `EventSourceParserStream` from `eventsource-parser/stream`: `response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream())`, keeping only the DeepSeek protocol shim (~10–25 lines): yield each event's `data`, terminate on `[DONE]`, and throw `LlmError('STREAM_CLOSED')` when the stream ends without the sentinel. All required builtins (`TextDecoderStream`, `pipeThrough`, async-iterable `ReadableStream`) exist at the Node ^22.19 engine floor. Delete the spec-conformance tests; keep the `[DONE]`/`STREAM_CLOSED`/EOF contract tests. Add `eventsource-parser` to `llm-deepseek`'s dependencies (its second runtime dep after schemastery). Update the [twin-adapters note](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) and the `dsh-llm` JSDoc that brand this adapter "hand-rolled fetch + SSE parsing" in the same PR.
|
||||
|
||||
The library also strips a leading BOM (the hand-rolled parser would fail to match `data:` after one) and offers `maxBufferSize` hardening the current parser lacks.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the hand-rolled parser.** Defensible under the [twin-adapters decision](../../implemented/architecture/2026-06-13-twin-llm-adapters.md): the adapter is deliberately the hand-rolled design-verification twin of the pi-ai adapter. But the note's load-bearing distinction is owning the fetch/translate internals versus delegating to a full provider SDK; a ~700-byte SSE micro-parser is transport plumbing, not the design under verification. Whether that reading stands is the twin-note owner's call — this proposal explicitly needs their sign-off.
|
||||
- **`createParser({onEvent})` callback API instead of the stream.** Works fed by a manual `TextDecoder` loop, but the `pipeThrough` composition deletes more of the hand-rolled code.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `sse.ts`'s parsing internals are gone; the remaining shim only encodes the DeepSeek `[DONE]`/`STREAM_CLOSED` protocol.
|
||||
- `llm-deepseek` unit tests and the real-API e2e suite pass; keyless snapshots are unchanged (parsing is transport-internal and payload extraction is equivalent).
|
||||
- The twin-adapters note and `dsh-llm` JSDoc no longer claim hand-rolled SSE parsing.
|
||||
|
||||
## Risks
|
||||
|
||||
- One deliberate robustness deviation is lost: the hand-rolled parser flushes a final event block that lacks its terminating blank line, and `tests/sse.spec.ts` pins that a trailing `data: [DONE]` without `\n\n` still yields DONE. eventsource-parser is spec-strict and only dispatches on the blank line, so that shape becomes `STREAM_CLOSED`. Real providers and `dsh-llm-mock-server` always terminate events properly, so the pinned behavior is a robustness nicety, not an observed provider shape — drop the test, or keep a tiny buffer-tail check if the deviation is judged load-bearing.
|
||||
- Dilutes the documented "hand-rolled" identity of the twin adapter; mitigated by updating the note in the same change rather than leaving the claim stale.
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
# Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE 解析器
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-26-eventsource-parser-for-deepseek-sse.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`packages/llm/llm-deepseek/src/sse.ts` 手写实现了 SSE(Server-Sent Events)解析:一个流式 `TextDecoder`、按 `\r?\n\r?\n` 切分事件块、提取并拼接 `data:` 载荷、跳过注释与其他字段、`[DONE]` 哨兵、在未见哨兵即 EOF 时抛出 `STREAM_CLOSED` 错误,以及对最后一个未终结事件块的 flush。该文件约 67 行,另有约 108 行专属测试(`tests/sse.spec.ts`)重复验证 SSE 规范行为——UTF-8 字符被切分到多个分片、CRLF 处理、多条 `data:` 拼接、冒号后无空格——而这些行为,持续维护的解析器早已有保证。它唯一的消费方是 `adapter.ts`(`yield* translate(parseSse(response.body))`)。
|
||||
|
||||
这恰好是 `eventsource-parser` 负责的接口面:事实标准的 SSE 解析器(Vercel AI SDK 和 MCP SDK 都构建在它之上),零依赖,持续维护,并且已通过 `@modelcontextprotocol/sdk` 作为传递依赖出现在本仓库的 lockfile 中——因此直接采用它实际上不增加新的供应链接触面。
|
||||
|
||||
## 提案
|
||||
|
||||
用 `eventsource-parser/stream` 的 `EventSourceParserStream` 替换 `sse.ts`:`response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream())`,只保留 DeepSeek 协议垫层(约 10–25 行):逐个产出事件的 `data`,遇到 `[DONE]` 终止,流在未见哨兵时结束则抛出 `LlmError('STREAM_CLOSED')`。所需的全部内置能力(`TextDecoderStream`、`pipeThrough`、可异步迭代的 `ReadableStream`)在 Node ^22.19 引擎下限即已存在。删除规范符合性测试;保留 `[DONE]`/`STREAM_CLOSED`/EOF 契约测试。将 `eventsource-parser` 加入 `llm-deepseek` 的依赖(这是它继 schemastery 之后的第二个运行时依赖)。在同一个 PR(Pull Request)中更新[孪生适配器 Agent Note(agent 决策记录)](../../implemented/architecture/2026-06-13-twin-llm-adapters.md)以及 `dsh-llm` 中把该适配器标为「手写 fetch + SSE 解析」的 JSDoc。
|
||||
|
||||
该库还会剥离开头的 BOM(手写解析器在 BOM 之后会无法匹配 `data:`),并提供当前解析器缺少的 `maxBufferSize` 加固能力。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **保留手写解析器。** 依据[孪生适配器决策](../../implemented/architecture/2026-06-13-twin-llm-adapters.md),这一选择有辩护余地:该适配器有意作为 pi-ai 适配器的手写设计验证孪生体。但那份 Agent Note 起支撑作用的区分在于「自行持有 fetch/translate 内部实现」与「委托给完整的提供方 SDK」;一个约 700 字节的 SSE 微型解析器属于传输层管道,不是被验证的设计本身。这一解读是否成立由孪生 Agent Note 的所有者裁定——本提案明确需要其签署确认。
|
||||
- **改用 `createParser({onEvent})` 回调 API 而非流。** 配合手动的 `TextDecoder` 循环可以工作,但 `pipeThrough` 组合方式能删除更多手写代码。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- `sse.ts` 的解析内部实现消失;剩下的垫层只编码 DeepSeek 的 `[DONE]`/`STREAM_CLOSED` 协议。
|
||||
- `llm-deepseek` 单元测试与真实 API 的 e2e 套件通过;无密钥快照不变(解析属于传输层内部,载荷提取等价)。
|
||||
- 孪生适配器 Agent Note 与 `dsh-llm` 的 JSDoc 不再声称手写 SSE 解析。
|
||||
|
||||
## 风险
|
||||
|
||||
- 会失去一处有意为之的健壮性偏离:手写解析器会 flush 缺少终结空行的最后一个事件块,`tests/sse.spec.ts` 固定了「末尾的 `data: [DONE]` 即使没有 `\n\n` 也仍产出 DONE」这一行为。eventsource-parser 严格遵循规范,只在空行处分发事件,因此这种形态会变成 `STREAM_CLOSED`。真实提供方和 `dsh-llm-mock-server` 总是正确终结事件,所以被固定的行为只是健壮性上的锦上添花,并非实际观测到的提供方形态:可以删除该测试;若判定该偏离确有支撑作用,也可以保留一个小型的缓冲区尾部检查。
|
||||
- 稀释了孪生适配器有文档记录的「手写」身份;缓解方式是在同一次变更中更新那份 Agent Note,而不是让声明陈旧下去。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-26-dependency-swaps-rejected-by-nih-audit.md: a1d15b89f85b41e1044d9597dee6a1a0190240e6
|
||||
2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 4893784effdee7605f9194a80010b5a5033edbc1
|
||||
2026-07-26-dependency-swaps-rejected-by-nih-audit.md: f55bc2a9b7fb2a9599760734fca2a295665b95a2
|
||||
2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: f740e0717cffd1b2ff4f3f5db8c9775afdfe79f0
|
||||
+1
-1
@@ -18,7 +18,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu
|
||||
- **`vscode-languageserver-types` for lsp-local's wire-type subset**: ~80 type lines and ~45 guard lines, but upstream guards differ in both directions (accept `uri: undefined` the repo must reject; require `targetRange` the repo tolerates absent), and the initialize-result shapes live in `vscode-languageserver-protocol`, dragging `vscode-jsonrpc` in as a runtime dep — ~1 MB for 80 spec-exact lines.
|
||||
- **`json-rpc-2.0` for `dsh-jsonrpc`**: deletable correlation/dispatch is real (~100–130 lines) but the NDJSON wire must stay bit-identical for the hand-rolled Python SDK client, the package is single-maintainer, and the [GUI RPC note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) already treats this package as a frozen narrow surface. `vscode-jsonrpc` is a worse fit still (Content-Length framing, cancellation vocabulary the protocol lacks).
|
||||
- **`jsonrpcclient` for the Python SDK client**: v4 builds/parses messages only — ~20 lines — while the 500 lines that matter (subprocess lifecycle, threaded reader, id correlation, bidirectional server-role responses) stay; the library is in low-maintenance mode.
|
||||
- **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [llm-deepseek proposal](../../proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.)
|
||||
- **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [llm-deepseek proposal](../../implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.)
|
||||
|
||||
**Retry, timers, async:**
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门
|
||||
- **以 `vscode-languageserver-types` 承担 lsp-local 的协议类型子集**:约 80 行类型加约 45 行守卫,但上游守卫在两个方向上都与本仓库不一致(接受本仓库必须拒绝的 `uri: undefined`;强制要求本仓库容忍缺失的 `targetRange`),而且 initialize 结果的形状住在 `vscode-languageserver-protocol` 里,会把 `vscode-jsonrpc` 拖成运行时依赖——为 80 行严格贴合规范的代码付出约 1 MB。
|
||||
- **以 `json-rpc-2.0` 替换 `dsh-jsonrpc`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且 [GUI RPC 决策](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)已把这个包当作冻结的窄接口面对待。`vscode-jsonrpc` 更不合适(Content-Length 分帧、该协议并不具备的取消词汇)。
|
||||
- **以 `jsonrpcclient` 承担 Python SDK 客户端**:v4 只做消息的构造/解析——约 20 行——而真正要紧的 500 行(子进程生命周期、线程化读取器、id 关联、双向的服务端角色应答)全都保留;该库处于低维护模式。
|
||||
- **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比 [llm-deepseek 提案](../../proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。)
|
||||
- **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比 [llm-deepseek 提案](../../implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。)
|
||||
|
||||
**重试、定时器与异步:**
|
||||
|
||||
|
||||
@@ -46,4 +46,4 @@ Apply the ordered relocate-condense-raise policy in [docs/AGENTS.md](../../../do
|
||||
|
||||
## Validation and PR hygiene
|
||||
|
||||
Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow [dsh-translate-docs](../dsh-translate-docs/SKILL.md) and run `pnpm run verify-translation-pairing --write`. The PR body should give word deltas, explain any deliberately long exception, and list checks.
|
||||
Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow [dsh-translate-docs](../dsh-translate-docs/SKILL.md) and run `pnpm run verify-translation-pairing --write <pair>`. The PR body should give word deltas, explain any deliberately long exception, and list checks.
|
||||
@@ -5,17 +5,33 @@ description: Use when creating or updating the bilingual counterpart of a doc in
|
||||
|
||||
# Translating DeepSeek-Harness docs
|
||||
|
||||
## Delegate to a subagent
|
||||
|
||||
When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work. If you are that delegated subagent, skip this section; the sections from here on address the agent actually writing the translation.
|
||||
|
||||
## What this skill is
|
||||
|
||||
**This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not.
|
||||
|
||||
## Sources of truth (read, don't re-summarize)
|
||||
## Triage by change type — this decides everything else
|
||||
|
||||
These are authoritative; read them at the source so this skill never drifts out of sync.
|
||||
- **Update** (pair exists, one side edited): follow [the update path](#the-update-path-briefing-driven). It is briefing-driven and deliberately cheap: no guidance-corpus reading, no git archaeology, smallest counterpart edit. Never re-translate a whole document to apply an update — a minimal update preserves the reviewed phrasing of everything that didn't change; a re-translation throws that review away.
|
||||
- **New pair** (no counterpart yet): follow [the whole-document path](#the-whole-document-path-new-pairs).
|
||||
- **Deleted or renamed doc**: delete or rename the counterpart and the `.i18n.yaml` alongside it — the gate reports an incomplete pair otherwise.
|
||||
|
||||
Frozen Agent Notes under `.agents/notes/archived/` are not translation work. Their complete triplets are sealed by the archive verifier; never update, re-record, or repair either side after archival.
|
||||
|
||||
## The update path (briefing-driven)
|
||||
|
||||
Benchmarked on real pair updates from this repo's history, the briefing-driven path costs a fraction of a guidance-corpus-loading run at equal measured quality; the [briefed-updates Agent Note](../../notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md) holds the evidence.
|
||||
|
||||
1. **Generate the briefing**: `pnpm run gen-translation-brief <any file of the pair>` (no arguments briefs every out-of-sync pair). The briefing maps the change at the narrowest safely aligned granularity — changed Markdown units (paragraph, table row, list item, heading), then whole heading sections, then whole document — and contains the authored side's diff since the last confirmed-consistent state, each changed unit's last-confirmed source, current source, and current counterpart text (with line numbers), the terminology rows the change touches, first-occurrence movement notes, and a digest of the binding update rules.
|
||||
2. **Mechanical-only diff? `--apply` it.** When every change lies inside code fences that the pair shares byte-identically, the briefing says so; `pnpm run gen-translation-brief --apply <pair>` splices the edited fences into the counterpart and structure-validates the result before writing — no subagent, no hand-editing.
|
||||
3. **Prose diff? Delegate to a subagent, passing the briefing** (or the command to generate it). The briefing is the translator's whole working set — the subagent does not re-read the guidance corpus (the rules digest, terminology rows, and each changed unit's three-way context are inline) and does not re-derive the diff. It escalates to the whole-document path's sources of truth only when the briefing leaves a specific decision genuinely unanswerable — an unlisted term with no precedent in the surrounding text, or a whole-document briefing (`BOTH sides changed`, or neither units nor sections align), which always means reconciling by hand under [translation-rules.md](../../../docs/i18n/translation-rules.md).
|
||||
4. **Smallest edit that covers the diff.** Preserve the reviewed phrasing of everything the diff does not touch, then verify the changed hunks clause by clause against the source: nothing added, nothing dropped, terminology per the inline rows, code spans verbatim.
|
||||
5. **Record and verify, scoped**: `pnpm run verify-translation-pairing --write <pair>` then `pnpm run verify-translation-pairing <pair>`. `--write` names exactly the pairs you confirmed — it refuses to run bare so a bulk re-record is always an explicit `--all`. The corpus-wide check still runs in `doc-sync`/CI; do not run it per-update.
|
||||
|
||||
## The whole-document path (new pairs)
|
||||
|
||||
When translations need to be written from scratch, the orchestrating agent does not translate: spawn a subagent to do the translation work. The translator reads the sources of truth below first, then translates the whole file into the other language — section by section for long documents, keeping each section's structure locked to the source as you go rather than fixing structure at the end.
|
||||
|
||||
### Sources of truth (read, don't re-summarize)
|
||||
|
||||
- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope, and exclusions.
|
||||
- **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels).
|
||||
@@ -23,29 +39,7 @@ These are authoritative; read them at the source so this skill never drifts out
|
||||
- **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's calibrated machine-consumed template. Agents using this skill do not render it; the terminology table is the only repository file the automated renderer injects, while this skill and `translation-rules.md` remain binding for agent-authored translations.
|
||||
- **[dsh-prose-standard](../dsh-prose-standard/SKILL.md)** — required prose coverage and editorial judgment. Apply it to both sides without adding or dropping source propositions.
|
||||
|
||||
## Find the work
|
||||
|
||||
- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / out-of-sync / ok. Missing and out-of-sync rows are contract violations; the normal check rejects them.
|
||||
- In a PR that edits paired docs, the work list is the diff itself: every changed side of a pair needs its counterpart updated and the pair re-recorded in the same PR, and the gate goes red if you forget.
|
||||
|
||||
## Triage by change type
|
||||
|
||||
Do not process every file the same way:
|
||||
|
||||
- **New pair** (no counterpart yet): whichever language exists — English or Chinese — translate the whole file into the other, section by section for long documents, keeping each section's structure locked to the source as you go rather than fixing structure at the end.
|
||||
- **Update** (pair exists, one side edited): do NOT re-translate. The consistency record names the exact last-confirmed text of both sides — recover the edited side's previous state and diff:
|
||||
|
||||
```sh
|
||||
git cat-file -p <hash-from-i18n-yaml> > /tmp/last-confirmed.md
|
||||
git diff --no-index /tmp/last-confirmed.md docs/foo.md
|
||||
```
|
||||
|
||||
Apply the smallest counterpart edits that cover that diff. A minimal update preserves the reviewed phrasing of everything that didn't change; a re-translation throws that review away.
|
||||
- **Deleted or renamed doc**: delete or rename the counterpart and the `.i18n.yaml` alongside it — the gate reports an incomplete pair otherwise.
|
||||
|
||||
Frozen Agent Notes under `.agents/notes/archived/` are not translation work. Their complete triplets are sealed by the archive verifier; never update, re-record, or repair either side after archival.
|
||||
|
||||
## Translate
|
||||
### Translate
|
||||
|
||||
- **Pass 1 — write, don't transpose.** Read a semantic unit, then restate it as a native technical author in the nearest [style sample's](../../../docs/i18n/style-samples.md) register. Preserve the required frame without forcing sentence-by-sentence correspondence.
|
||||
- **Pass 2 — verify against the source, clause by clause.** Fidelity is checked here, not written in: confirm nothing was added or dropped, every term follows the table, and each code span survived verbatim. Fix by rewriting the sentence natively, not by patching words into it.
|
||||
@@ -54,15 +48,19 @@ Frozen Agent Notes under `.agents/notes/archived/` are not translation work. The
|
||||
- Code blocks are byte-identical across the pair, comments included. Relative links keep their `.md` targets; only the switcher line links `.zh.md`.
|
||||
- The pairing gate checks heading depths, fenced blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. In Pass 2, manually verify list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.
|
||||
|
||||
## Find the work
|
||||
|
||||
- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / out-of-sync / ok. Missing and out-of-sync rows are contract violations; the normal check rejects them.
|
||||
- `pnpm run gen-translation-brief` with no arguments prints the briefing for every out-of-sync pair.
|
||||
- In a PR that edits paired docs, the work list is the diff itself: every changed side of a pair needs its counterpart updated and the pair re-recorded in the same PR, and the gate goes red if you forget.
|
||||
|
||||
## Finish the pair
|
||||
|
||||
1. Switcher: `[English](foo.md) | 中文` immediately after the Chinese file's H1, `English | [中文](foo.zh.md)` after the English file's H1 — add both if this is a new pair.
|
||||
2. Record consistency: `pnpm run verify-translation-pairing --write` recomputes and records both sides' full blob hashes in `foo.i18n.yaml`. The yaml diff in your PR is the reviewable statement "I confirmed these two say the same thing" — only run it after you actually have.
|
||||
2. Record consistency: `pnpm run verify-translation-pairing --write <pair>` recomputes and records both sides' full blob hashes in `foo.i18n.yaml`. The yaml diff in your PR is the reviewable statement "I confirmed these two say the same thing" — only run it after you actually have.
|
||||
3. No manifest entry is needed for an ordinary document: every in-scope source requires a pair. Change [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) only when the owning policy documents a genuine generated, instructional, or bilingual-by-construction exclusion.
|
||||
|
||||
## Verify the mechanical and human halves
|
||||
|
||||
Run `pnpm run verify-translation-pairing`, then the rest of the Markdown gates (`pnpm run verify-md-wrap && pnpm run verify-md-links`, or full `pnpm run doc-sync` before the PR). Fix what they report and manually verify the obligations listed in Pass 2 that the gates do not encode. Keep the PR reviewable: state which pairs are new versus minimally updated and list 「待定术语」 prominently.
|
||||
4. Before the PR: the touched pairs are green under the scoped check; `pnpm run doc-sync` (which includes the corpus-wide pairing check plus `verify-md-wrap`/`verify-md-links`) runs once at PR level per [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md), not inside each translation task.
|
||||
5. Keep the PR reviewable: state which pairs are new versus minimally updated and list 「待定术语」 prominently.
|
||||
|
||||
## How to respond to translation review
|
||||
|
||||
|
||||
@@ -693,8 +693,8 @@ jobs:
|
||||
# 'cancelled' and 'skipped'.
|
||||
all-checks-passed:
|
||||
name: all checks passed
|
||||
# The required verdict must not add a separate standard-hosted billing dependency.
|
||||
runs-on: dsh-enterprise-ubuntu-latest-32core-test
|
||||
# This bookkeeping-only verdict must not depend on custom-pool provisioning.
|
||||
runs-on: ubuntu-latest
|
||||
needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows]
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
steps:
|
||||
|
||||
+1
-1
@@ -7,8 +7,8 @@ pnpm-debug.log
|
||||
.pnpm-store/
|
||||
.cache/
|
||||
examples/*/*.jsonl
|
||||
.sessions/
|
||||
.storages/
|
||||
.sessions/
|
||||
examples/*/.sessions/
|
||||
coverage/
|
||||
.doc-typecheck-*/
|
||||
|
||||
@@ -12,7 +12,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every
|
||||
vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md
|
||||
packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
|
||||
core/ product API spine: session, system-prompt, tools, agent, agent-loop
|
||||
llm/ LLM seam + DeepSeek adapters (hand-rolled + pi-ai design twin)
|
||||
llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin)
|
||||
bash/ bash executor seam + local impl + model-facing bash tools
|
||||
pty/ persistent PTY seam/backend/tools
|
||||
fs/ filesystem seam + local impl + policy gate + read/write/edit tools
|
||||
|
||||
@@ -145,6 +145,30 @@
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
# Host command registry: the single source of truth behind command.list /
|
||||
# command.execute; the web '/' menu is a pure projection of this registry.
|
||||
- id: commands
|
||||
name: '@deepseek-ai/dsh-commands'
|
||||
|
||||
# Plan mode registers /plan (the first real command on the web surface).
|
||||
# Section text mirrors examples/tui-agent/cordis.yml (the reference
|
||||
# deployment); plan-mode throws at load on an empty section.
|
||||
- id: plan-mode
|
||||
name: '@deepseek-ai/dsh-plan-mode'
|
||||
config:
|
||||
section: |
|
||||
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
|
||||
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
||||
|
||||
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
|
||||
|
||||
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
|
||||
|
||||
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
|
||||
|
||||
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
|
||||
|
||||
# token-meter rejects unknown config keys — keep this row bare.
|
||||
- id: token-meter
|
||||
name: '@deepseek-ai/dsh-token-meter'
|
||||
@@ -262,6 +286,20 @@
|
||||
- id: ui-workspace
|
||||
name: '@deepseek-ai/dsh-client-ui-workspace'
|
||||
|
||||
# Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over
|
||||
# it (ui-command), and the two reference sources (ui-skill / ui-subagent).
|
||||
- id: ui-slash
|
||||
name: '@deepseek-ai/dsh-client-ui-slash'
|
||||
|
||||
- id: ui-command
|
||||
name: '@deepseek-ai/dsh-client-ui-command'
|
||||
|
||||
- id: ui-skill
|
||||
name: '@deepseek-ai/dsh-client-ui-skill'
|
||||
|
||||
- id: ui-subagent
|
||||
name: '@deepseek-ai/dsh-client-ui-subagent'
|
||||
|
||||
- id: ui-question
|
||||
name: '@deepseek-ai/dsh-client-ui-question'
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
|
||||
@@ -33,10 +34,14 @@
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-frontend": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
@@ -47,6 +52,7 @@
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
|
||||
@@ -144,7 +144,7 @@ it('renders the fixture run_code turn: code parent row, nested sub-rows, error s
|
||||
"errorSubRow": true,
|
||||
"parentRow": "CodeRead the notes files and summarize",
|
||||
"subRows": [
|
||||
"$List notes",
|
||||
"BashList notes",
|
||||
"Readnotes/demo.txt",
|
||||
"Readnotes/missing.txt",
|
||||
],
|
||||
@@ -213,9 +213,9 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"subCells": [
|
||||
"#53Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
|
||||
"#54Subread · {"path":"notes/demo.txt"}+0.8s",
|
||||
"#55Subread · {"path":"notes/missing.txt"}+0.8s",
|
||||
"#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
|
||||
"#52Subread · {"path":"notes/demo.txt"}+0.8s",
|
||||
"#53Subread · {"path":"notes/missing.txt"}+0.8s",
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url))
|
||||
@@ -48,6 +48,8 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
@@ -47,6 +47,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
@@ -94,6 +94,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,9 +136,11 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
await settled
|
||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
|
||||
// Composer recovered; no streaming node lingers.
|
||||
// Composer recovered; no streaming node lingers. The host settled first
|
||||
// (awaited above), but the abort frame reaches the browser over SSE — the
|
||||
// frozen-partial swap is eventually consistent, so poll rather than count.
|
||||
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
|
||||
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
|
||||
// Golden of the aborted end-state: the prompt bubble plus the frozen
|
||||
// partial ('partial' is the hang entry's replayed prefix) and no more.
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
@@ -45,6 +45,8 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
|
||||
@@ -47,6 +47,8 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
|
||||
|
||||
// Replay publishes the provider catalog the gateway routes to (providers
|
||||
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
|
||||
// catch-all would leave resolveModelContext unroutable and compact-basic's
|
||||
// catch-all would leave resolveModelInfo unroutable and compact-basic's
|
||||
// post-step pressure check would warn every step). The published
|
||||
// contextWindow keeps that pressure path provably inert for small fixtures.
|
||||
const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled keyless snapshot of the slash/input/session convergence under the
|
||||
// agent-parity model: the New Session view state locks the composer until a
|
||||
// Workspace is picked (connectWorkspace materializes the full Session+Agent),
|
||||
// the '/' menu serves the session's wire command catalog (sessions are always
|
||||
// agent-backed — no draft/materialized split), a leadingInput command claims,
|
||||
// submits over the wire, and notices its result, and the SAME composer
|
||||
// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
|
||||
// flips blank and surfaces the session in lists. This is the user-visible
|
||||
// acceptance anchor — package mocks do not substitute for the assembled
|
||||
// application transcript.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against one keyless fixture branch. */
|
||||
function boot(search: string): void {
|
||||
history.replaceState(null, '', `/${search}`)
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Type into the machine-driven composer and let the change echo back. */
|
||||
async function typeComposer(composer: HTMLTextAreaElement, value: string): Promise<void> {
|
||||
fireEvent.change(composer, { target: { value } })
|
||||
await waitFor(() => { expect(composer.value).toBe(value) })
|
||||
}
|
||||
|
||||
it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
// View state: no session entity — the composer renders locked; only the
|
||||
// workspace picker is live.
|
||||
const locked = await screen.findByPlaceholderText<HTMLTextAreaElement>(
|
||||
'Choose a workspace to start', {}, { timeout: 10_000 },
|
||||
)
|
||||
expect(locked.disabled).toBe(true)
|
||||
|
||||
// Pick (create) a Workspace: connectWorkspace materializes the full
|
||||
// Session+Agent and the provider swaps in the live blank-session hero.
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
|
||||
.find(el => el.getAttribute('aria-haspopup') === 'menu')!)
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
|
||||
target: { value: 'nova' },
|
||||
})
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
const composer = await screen.findByPlaceholderText<HTMLTextAreaElement>(
|
||||
'Describe what you want to build', {}, { timeout: 10_000 },
|
||||
)
|
||||
expect(composer.disabled).toBe(false)
|
||||
|
||||
// '/' opens the menu with the session's wire command catalog (the session
|
||||
// is agent-backed from birth — the catalog is the single-address list).
|
||||
await typeComposer(composer, '/')
|
||||
const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await waitFor(() => { expect(visibleText(menu)).toContain('echo') })
|
||||
const menuText = visibleText(menu)
|
||||
|
||||
// Pick /echo (leadingInput): the claim token lands in the same textarea.
|
||||
fireEvent.mouseDown(screen.getByRole('option', { name: /echo/ }))
|
||||
await waitFor(() => { expect(composer.value).toBe('/echo ') })
|
||||
|
||||
// Type args and submit: the claim executes over the wire and notices its
|
||||
// result; the token is consumed and the draft returns to plain text.
|
||||
await typeComposer(composer, '/echo hello parser')
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await screen.findByText('hello parser', {}, { timeout: 10_000 })
|
||||
await waitFor(() => { expect(composer.value).toBe('') })
|
||||
|
||||
// Slash execution does not flip blank: the selected row remains New Session.
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
expect(within(tree).getByText('1 session')).toBeDefined()
|
||||
expect(within(tree).getByText('New Session')).toBeDefined()
|
||||
|
||||
// First plain send through the SAME textarea: acceptance logs the user
|
||||
// message and converts the existing sidebar row out of blank.
|
||||
const before = composer
|
||||
await typeComposer(composer, 'build me a parser')
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Let's start building")).toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
const after = document.querySelector('textarea')
|
||||
|
||||
expect({
|
||||
menuHadEcho: menuText.includes('echo'),
|
||||
menuHadCompact: menuText.includes('compact'),
|
||||
composerSurvivedConversion: after === before,
|
||||
sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"composerSurvivedConversion": true,
|
||||
"menuHadCompact": true,
|
||||
"menuHadEcho": true,
|
||||
"sessionListed": "nova1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -24,7 +24,7 @@ import { pathToFileURL } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { REPO_ROOT, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Repo-root .env → process.env (never overrides an already-set variable). */
|
||||
function loadRootEnv(): void {
|
||||
@@ -404,6 +404,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
|
||||
it('2+3 empty-state first send completes a real model round', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
|
||||
// Fresh world: connect a Workspace so the composer starts live.
|
||||
await connectFreshWorkspace(page)
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
await screen(page, '02-empty-state')
|
||||
|
||||
@@ -23,13 +23,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- combobox "Model":
|
||||
- option "DeepSeek-V4-Pro High" [selected]
|
||||
- option "DeepSeek-V4-Pro"
|
||||
- button "Send message" [disabled]
|
||||
@@ -19,13 +19,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- combobox "Model":
|
||||
- option "DeepSeek-V4-Pro High" [selected]
|
||||
- option "DeepSeek-V4-Pro"
|
||||
- button "Send message" [disabled]
|
||||
@@ -11,7 +11,11 @@
|
||||
- button "Search sessions":
|
||||
- img
|
||||
- textbox "Search name, keywords..."
|
||||
- tree "Sessions": No sessions yet
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- treeitem "New Session now" [selected]
|
||||
- button "设置":
|
||||
- img
|
||||
- text: 设置
|
||||
@@ -23,13 +27,10 @@
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- combobox "Model":
|
||||
- option "DeepSeek-V4-Pro High" [selected]
|
||||
- option "DeepSeek-V4-Pro"
|
||||
- button "Send message" [disabled]
|
||||
- text: 详情
|
||||
- button "关闭详情"
|
||||
- text: 点击消息流中的工具行查看详情
|
||||
@@ -15,13 +15,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- combobox "Model":
|
||||
- option "DeepSeek-V4-Pro High" [selected]
|
||||
- option "DeepSeek-V4-Pro"
|
||||
- button "Send message" [disabled]
|
||||
@@ -12,13 +12,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- combobox "Model":
|
||||
- option "DeepSeek-V4-Pro High" [selected]
|
||||
- option "DeepSeek-V4-Pro"
|
||||
- button "Send message" [disabled]
|
||||
@@ -10,13 +10,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- combobox "Model":
|
||||
- option "DeepSeek-V4-Pro High" [selected]
|
||||
- option "DeepSeek-V4-Pro"
|
||||
- button "Send message" [disabled]
|
||||
@@ -15,13 +15,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- combobox "Model":
|
||||
- option "DeepSeek-V4-Pro High" [selected]
|
||||
- option "DeepSeek-V4-Pro"
|
||||
- button "Send message" [disabled]
|
||||
@@ -1,3 +1,5 @@
|
||||
- text: bash
|
||||
- button "关闭详情"
|
||||
- text: "Input { \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" } Output NAVIGATION_OK"
|
||||
- text: Input
|
||||
- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }"
|
||||
- text: Output NAVIGATION_OK
|
||||
@@ -21,13 +21,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- combobox "Model":
|
||||
- option "DeepSeek-V4-Pro High" [selected]
|
||||
- option "DeepSeek-V4-Pro"
|
||||
- button "Send message" [disabled]
|
||||
@@ -24,13 +24,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- combobox "Model":
|
||||
- option "DeepSeek-V4-Pro High" [selected]
|
||||
- option "DeepSeek-V4-Pro"
|
||||
- button "Send message" [disabled]
|
||||
@@ -21,13 +21,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- combobox "Plan mode":
|
||||
- option "Plan" [selected]
|
||||
- option "Agent"
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- combobox "Model":
|
||||
- option "DeepSeek-V4-Pro High" [selected]
|
||||
- option "DeepSeek-V4-Pro"
|
||||
- button "Send message" [disabled]
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
@@ -71,6 +71,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -32,6 +32,31 @@ export function probeFreePort(): Promise<number> {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the hero's workspace picker through its create-by-name dialog until
|
||||
* the live composer unlocks. A fresh world has no Workspace, so the boot
|
||||
* lands in the locked view state (startup auto-selection has nothing to
|
||||
* select); every scenario that types into the composer must connect one
|
||||
* first. The default name 'workspace' keeps the session header cwd at
|
||||
* <workspaceRoot>/workspace — the materialization proof several scenarios
|
||||
* assert.
|
||||
* @param page - the page under test.
|
||||
* @param name - workspace name typed into the create dialog.
|
||||
*/
|
||||
export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Choose workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
|
||||
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByLabel('New workspace name').fill(name)
|
||||
await dialog.getByRole('button', { name: 'Create workspace' }).click()
|
||||
// The pick connected the workspace: the blank session's live composer
|
||||
// replaces the locked placeholder and enables.
|
||||
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
|
||||
.waitFor({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
/** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
|
||||
export async function saveFailureShot(page: Page, name: string): Promise<void> {
|
||||
const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// @vitest-environment jsdom
|
||||
// Todo display snapshot over the BUILT client graph (the code-mode-fixture
|
||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
||||
// Opens the fixture history session and pins the todo_write turn's two
|
||||
// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary
|
||||
// derived from the call args) and the TodoPanel plan strip riding the
|
||||
// 'conversation.input.dock' slot (fed by ConversationSnapshot.todos, seeded
|
||||
// by the tail history page), including the collapse interaction.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against the populated fixture branch. */
|
||||
function boot(): void {
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */
|
||||
async function openFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
// Anchor on the expandable Workspace group row: the title and the blank
|
||||
// session row can both read "fixture", and the session-count meta shifts
|
||||
// when a blank session joins the group.
|
||||
const group = (await within(tree).findAllByText('fixture'))
|
||||
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
|
||||
.find(el => el?.getAttribute('aria-expanded') !== null)
|
||||
if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
|
||||
if (group.getAttribute('aria-expanded') === 'false') {
|
||||
fireEvent.click(within(group).getByText('fixture'))
|
||||
await waitFor(() => {
|
||||
expect(group.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
}
|
||||
const session = await within(tree).findByText('Fixture 历史会话')
|
||||
fireEvent.click(session)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
const row = document.querySelector('[data-sample="todo-row"]')
|
||||
if (row === null) throw new Error('todo row missing')
|
||||
const panel = document.querySelector('[data-testid="todo-panel"]')
|
||||
if (panel === null) throw new Error('todo panel missing from the input dock')
|
||||
|
||||
expect({
|
||||
row: visibleText(row),
|
||||
rowState: row.getAttribute('data-state'),
|
||||
panelHeader: visibleText(panel.querySelector('button') ?? panel),
|
||||
panelItems: [...panel.querySelectorAll('li')].map(item => ({
|
||||
status: item.getAttribute('data-status'),
|
||||
text: visibleText(item),
|
||||
})),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"panelHeader": "Plan1/3",
|
||||
"panelItems": [
|
||||
{
|
||||
"status": "completed",
|
||||
"text": "✓梳理需求",
|
||||
},
|
||||
{
|
||||
"status": "in_progress",
|
||||
"text": "●实现 fixture 样本",
|
||||
},
|
||||
{
|
||||
"status": "pending",
|
||||
"text": "○浏览器验收",
|
||||
},
|
||||
],
|
||||
"row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本",
|
||||
"rowState": "ok",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('collapses the plan strip to the in-progress hint and restores it', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
const panel = document.querySelector('[data-testid="todo-panel"]')
|
||||
if (panel === null) throw new Error('todo panel missing from the input dock')
|
||||
const header = panel.querySelector('button')
|
||||
if (header === null) throw new Error('todo panel header missing')
|
||||
|
||||
fireEvent.click(header)
|
||||
expect({
|
||||
collapsedHeader: visibleText(header),
|
||||
listGone: panel.querySelector('ul') === null,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"collapsedHeader": "Plan1/3实现 fixture 样本",
|
||||
"listGone": true,
|
||||
}
|
||||
`)
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(panel.querySelectorAll('li')).toHaveLength(3)
|
||||
})
|
||||
@@ -1,4 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled keyless snapshots of the New Session flow under the agent-parity
|
||||
// model: startup auto-connects the recent Workspace's blank session when one
|
||||
// exists; without any Workspace the composer is locked in the pure view
|
||||
// state until one is chosen. Picking one materializes the full Session+Agent
|
||||
// (reuse-or-create of the workspace's blank session), the first ACCEPTED
|
||||
// prompt flips blank and surfaces the session in lists, and failures leave
|
||||
// no client-side transaction state: a failed attach keeps the view state
|
||||
// locked, a rejected prompt keeps the session blank with the draft restored.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
@@ -93,25 +101,12 @@ function boot(search: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** Recreate the built client graph while preserving browser-persistent state. */
|
||||
function refresh(search: string): void {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
boot(search)
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Identify the interactive Workspace chip by its menu contract. */
|
||||
/** Identify the interactive Workspace chip (view state or blank-session hero) by its menu contract. */
|
||||
function workspaceChip(): HTMLElement {
|
||||
const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
|
||||
.find(element => element.getAttribute('aria-haspopup') === 'menu')
|
||||
@@ -119,210 +114,242 @@ function workspaceChip(): HTMLElement {
|
||||
return chip
|
||||
}
|
||||
|
||||
/** Edit the runtime-owned controlled input and assert the same-tick echo:
|
||||
* a deferred echo makes React roll the textarea back mid-IME-composition,
|
||||
* committing partial keystrokes (e.g. Pinyin "nihao" leaking as "nnini h…"). */
|
||||
/** The locked view-state composer (no session yet). */
|
||||
async function findLockedComposer(): Promise<HTMLTextAreaElement> {
|
||||
return await screen.findByPlaceholderText(
|
||||
'Choose a workspace to start', {}, { timeout: 10_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/** The live blank-session hero composer (session materialized). */
|
||||
async function findHeroComposer(): Promise<HTMLTextAreaElement> {
|
||||
return await screen.findByPlaceholderText(
|
||||
'Describe what you want to build', {}, { timeout: 10_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/** Edit the machine-owned controlled input and assert the same-tick echo. */
|
||||
function setComposerText(composer: HTMLElement, value: string): void {
|
||||
fireEvent.change(composer, { target: { value } })
|
||||
expect((composer as HTMLTextAreaElement).value).toBe(value)
|
||||
}
|
||||
|
||||
it('starts a writable page-local draft without inventing a sidebar Workspace', async () => {
|
||||
/** Drive the picker's create flow: chip → Create workspace → name dialog. */
|
||||
async function createWorkspaceViaPicker(name: string): Promise<void> {
|
||||
fireEvent.click(workspaceChip())
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
|
||||
target: { value: name },
|
||||
})
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
|
||||
}
|
||||
|
||||
/** Pick an existing Workspace row from the chip menu. */
|
||||
async function pickWorkspace(title: string): Promise<void> {
|
||||
fireEvent.click(workspaceChip())
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: title }))
|
||||
}
|
||||
|
||||
it('locks the composer in the New Session view state until a Workspace is chosen', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
const composer = await findLockedComposer()
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
setComposerText(composer, 'keep this local')
|
||||
|
||||
expect({
|
||||
headline: visibleText(screen.getByText("Let's start building")),
|
||||
workspaceDraft: visibleText(workspaceChip()),
|
||||
chip: visibleText(workspaceChip()),
|
||||
composerDisabled: composer.disabled,
|
||||
sendDisabled: screen.getByRole<HTMLButtonElement>('button', { name: 'Send message' }).disabled,
|
||||
sidebar: visibleText(tree),
|
||||
composerDisabled: (composer as HTMLTextAreaElement).disabled,
|
||||
prompt: (composer as HTMLTextAreaElement).value,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"composerDisabled": false,
|
||||
"chip": "New Workspace",
|
||||
"composerDisabled": true,
|
||||
"headline": "Let's start building",
|
||||
"prompt": "keep this local",
|
||||
"sendDisabled": true,
|
||||
"sidebar": "No sessions yet",
|
||||
"workspaceDraft": "workspace",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('creates a real empty Workspace immediately and focuses its Session draft', async () => {
|
||||
boot('?fixture=empty')
|
||||
it('selects the recent Workspace and opens its blank Session on first load', async () => {
|
||||
boot('?fixture')
|
||||
|
||||
await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
const workspaceSection = screen.getByText('Workspaces').parentElement
|
||||
if (workspaceSection === null) throw new Error('Workspace section missing')
|
||||
fireEvent.click(within(workspaceSection).getByRole('button', { name: 'Create workspace' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
|
||||
target: { value: 'nova' },
|
||||
})
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
const draft = within(tree).getByText('New session').closest('[role="treeitem"]')
|
||||
if (group === null || draft === null) throw new Error('created Workspace projection missing')
|
||||
const composer = await findHeroComposer()
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('4 sessions')).toBeDefined() }, { timeout: 10_000 })
|
||||
|
||||
expect({
|
||||
workspace: visibleText(group),
|
||||
draft: visibleText(draft),
|
||||
draftSelected: draft.getAttribute('aria-selected'),
|
||||
composerWorkspace: visibleText(workspaceChip()),
|
||||
chip: visibleText(workspaceChip()),
|
||||
composerDisabled: composer.disabled,
|
||||
blankRow: within(tree).getByText('New Session').textContent,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"composerWorkspace": "nova",
|
||||
"draft": "New session",
|
||||
"draftSelected": "true",
|
||||
"blankRow": "New Session",
|
||||
"chip": "fixture",
|
||||
"composerDisabled": false,
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('creating a Workspace materializes and lists its selected blank Session', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
await findLockedComposer()
|
||||
await createWorkspaceViaPicker('nova')
|
||||
|
||||
// The pick connected the workspace: full Session+Agent exists, composer live.
|
||||
const composer = await findHeroComposer()
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
|
||||
expect(within(tree).getByText('New Session')).toBeDefined()
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
if (group === null) throw new Error('created Workspace projection missing')
|
||||
|
||||
expect({
|
||||
composerDisabled: composer.disabled,
|
||||
chip: visibleText(workspaceChip()),
|
||||
workspace: visibleText(group),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"chip": "nova",
|
||||
"composerDisabled": false,
|
||||
"workspace": "nova1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => {
|
||||
boot('?fixture')
|
||||
it('New Session reuses the Workspace blank session and converts the single visible row', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
await findLockedComposer()
|
||||
await createWorkspaceViaPicker('nova')
|
||||
await findHeroComposer()
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
setComposerText(composer, 'discard this page-local draft')
|
||||
const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]')
|
||||
if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh')
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
|
||||
const before = {
|
||||
workspace: visibleText(beforeGroup),
|
||||
draft: visibleText(within(tree).getByText('New session')),
|
||||
prompt: (composer as HTMLTextAreaElement).value,
|
||||
}
|
||||
// New Session resolves through the recent Workspace and reuses its blank
|
||||
// session in place: no locked interlude, no second entity.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
|
||||
const composer = await findHeroComposer()
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
|
||||
refresh('?fixture')
|
||||
setComposerText(composer, 'first light')
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
|
||||
const refreshedComposer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
const refreshedTree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const afterGroup = within(refreshedTree).getByText('4 sessions').closest('[role="treeitem"]')
|
||||
if (afterGroup === null) throw new Error('fixture Workspace projection missing after refresh')
|
||||
// Conversion: the accepted prompt flips blank without adding a second row.
|
||||
await screen.findByText('first light', { exact: true }, { timeout: 10_000 })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
if (group === null) throw new Error('converted Session projection missing')
|
||||
|
||||
expect({
|
||||
before,
|
||||
after: {
|
||||
workspace: visibleText(afterGroup),
|
||||
replacementDraft: visibleText(within(refreshedTree).getByText('New session')),
|
||||
prompt: (refreshedComposer as HTMLTextAreaElement).value,
|
||||
},
|
||||
workspace: visibleText(group),
|
||||
promptVisible: screen.getByText('first light', { exact: true }).textContent,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"after": {
|
||||
"prompt": "",
|
||||
"replacementDraft": "New session",
|
||||
"workspace": "fixture4 sessions",
|
||||
},
|
||||
"before": {
|
||||
"draft": "New session",
|
||||
"prompt": "discard this page-local draft",
|
||||
"workspace": "fixture4 sessions",
|
||||
},
|
||||
"promptVisible": "first light",
|
||||
"workspace": "nova1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => {
|
||||
it('a failed Workspace attach recovers by reusing the published blank session', async () => {
|
||||
boot('?fixture&fixtureAttach=fail')
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
setComposerText(composer, 'keep this cwd-only session')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
|
||||
// The rejected startup connect surfaces the locked view state first: the
|
||||
// failure leaves no client-side transaction state to unwind.
|
||||
await findLockedComposer()
|
||||
|
||||
// The host published the session before rejecting attachment (blank, with
|
||||
// the workspace cwd), so the next connect — retry or manual pick — reuses
|
||||
// it instead of minting a duplicate, and the hero opens on it.
|
||||
await pickWorkspace('fixture')
|
||||
const composer = await findHeroComposer()
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('Ungrouped')).toBeDefined() }, { timeout: 10_000 })
|
||||
const workspaceGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
|
||||
const ungroupedGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
const ungroupedSection = ungroupedGroup?.parentElement
|
||||
if (workspaceGroup === null || ungroupedGroup === null || ungroupedSection === null || ungroupedSection === undefined) {
|
||||
throw new Error('Workspace or Ungrouped projection missing')
|
||||
}
|
||||
const session = within(ungroupedSection).getByRole('treeitem', { selected: true })
|
||||
const retained = screen.getByDisplayValue('keep this cwd-only session')
|
||||
const group = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
|
||||
if (group === null) throw new Error('fixture Workspace projection missing')
|
||||
|
||||
expect({
|
||||
workspace: visibleText(workspaceGroup),
|
||||
ungrouped: visibleText(ungroupedGroup),
|
||||
session: within(session).getByText('fixture', { exact: true }).textContent,
|
||||
sessionSelected: session.getAttribute('aria-selected'),
|
||||
prompt: (retained as HTMLTextAreaElement).value,
|
||||
headline: visibleText(screen.getByText("Let's start building")),
|
||||
composerDisabled: composer.disabled,
|
||||
chip: visibleText(workspaceChip()),
|
||||
workspace: visibleText(group),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"prompt": "keep this cwd-only session",
|
||||
"session": "fixture",
|
||||
"sessionSelected": "true",
|
||||
"ungrouped": "Ungrouped1 session",
|
||||
"chip": "fixture",
|
||||
"composerDisabled": false,
|
||||
"headline": "Let's start building",
|
||||
"workspace": "fixture3 sessions",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('materializes the automatic Workspace and Session on the first successful send', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
setComposerText(composer, 'build a lighthouse')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
|
||||
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
await screen.findByText('build a lighthouse', { exact: true }, { timeout: 10_000 })
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
const session = within(tree).getByRole('treeitem', { selected: true })
|
||||
if (group === null) throw new Error('materialized Workspace projection missing')
|
||||
|
||||
expect({
|
||||
workspace: visibleText(group),
|
||||
session: within(session).getByText('workspace', { exact: true }).textContent,
|
||||
sessionSelected: session.getAttribute('aria-selected'),
|
||||
promptVisible: screen.getByText('build a lighthouse', { exact: true }).textContent,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"promptVisible": "build a lighthouse",
|
||||
"session": "workspace",
|
||||
"sessionSelected": "true",
|
||||
"workspace": "workspace1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('keeps the published Workspace, Session, and unsent prompt after rejection', async () => {
|
||||
it('a rejected first prompt keeps the session blank and the draft in the machine', async () => {
|
||||
boot('?fixture=empty&fixturePrompt=reject')
|
||||
|
||||
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
await findLockedComposer()
|
||||
await createWorkspaceViaPicker('nova')
|
||||
const composer = await findHeroComposer()
|
||||
|
||||
setComposerText(composer, 'do not lose this')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
|
||||
|
||||
const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
|
||||
const retained = screen.getByDisplayValue('do not lose this')
|
||||
// Failure restore rides the machine (no pendingPrompt transaction): the
|
||||
// draft returns to the same resident textarea one render later. The
|
||||
// attempt flips the composer out of the hero (engaging = retry chrome),
|
||||
// but acceptance never happened: the session row stays New Session.
|
||||
const retained = await screen.findByDisplayValue('do not lose this')
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
|
||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
const session = within(tree).getByRole('treeitem', { selected: true })
|
||||
if (group === null) throw new Error('rejected-send Workspace projection missing')
|
||||
|
||||
expect({
|
||||
workspace: visibleText(group),
|
||||
session: within(session).getByText('workspace', { exact: true }).textContent,
|
||||
error: visibleText(alert),
|
||||
prompt: (retained as HTMLTextAreaElement).value,
|
||||
blankRow: within(tree).getByText('New Session').textContent,
|
||||
workspace: visibleText(group),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance",
|
||||
"blankRow": "New Session",
|
||||
"error": "fixture: prompt rejected before acceptance (agent-busy)",
|
||||
"prompt": "do not lose this",
|
||||
"session": "workspace",
|
||||
"workspace": "workspace1 session",
|
||||
"workspace": "nova1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('switching Workspace before the first message carries the draft to the new blank session', async () => {
|
||||
boot('?fixture')
|
||||
|
||||
const composer = await findHeroComposer()
|
||||
setComposerText(composer, 'carry me')
|
||||
|
||||
// Switch = session switch: the new workspace's blank session takes over,
|
||||
// the typed draft moves machine-to-machine, the old blank stays hidden.
|
||||
await createWorkspaceViaPicker('nova')
|
||||
await waitFor(() => { expect(visibleText(workspaceChip())).toBe('nova') }, { timeout: 10_000 })
|
||||
const carried = await screen.findByDisplayValue('carry me')
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const fixtureGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
|
||||
const novaGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
||||
if (fixtureGroup === null || novaGroup === null) throw new Error('Workspace projections missing after switch')
|
||||
|
||||
expect({
|
||||
chip: visibleText(workspaceChip()),
|
||||
prompt: (carried as HTMLTextAreaElement).value,
|
||||
fixtureWorkspace: visibleText(fixtureGroup),
|
||||
novaWorkspace: visibleText(novaGroup),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"chip": "nova",
|
||||
"fixtureWorkspace": "fixture3 sessions",
|
||||
"novaWorkspace": "nova1 session",
|
||||
"prompt": "carry me",
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
architecture.md: b426891c0483f42a64b597632cf1871aff79ca2d
|
||||
architecture.zh.md: 13feefb6854e79ddee38602902d325a789fd7744
|
||||
architecture.md: 34e56dd955e6ac5ad8fad236bdf7ae9cfc810a83
|
||||
architecture.zh.md: ea105b869eba799d00dfaa134ed34d14c7505fc8
|
||||
@@ -91,7 +91,7 @@ forever:
|
||||
agent/pre-step
|
||||
snapshot the derived messages (the reconstruction boundary)
|
||||
'step/start'
|
||||
agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen)
|
||||
agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound)
|
||||
on final adapter-path or terminal in-band failure:
|
||||
'step/end'
|
||||
agent/request-error(original error, failure facts, immutable prior failures, signal)
|
||||
@@ -125,7 +125,7 @@ Pruning precedes summaries; overflow retries require durable progress. Bounded r
|
||||
|
||||
Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retries open steps; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit nothing.
|
||||
|
||||
Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
|
||||
Other failures use `agent/error`. Cancellation and disposal beat recovery; the turn signal also cancels asynchronous model-capability preparation before any request header is committed, and undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
|
||||
|
||||
Session events are turn-enclosed; reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures use `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap).
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ forever:
|
||||
agent/pre-step
|
||||
snapshot the derived messages (the reconstruction boundary)
|
||||
'step/start'
|
||||
agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen)
|
||||
agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound)
|
||||
on final adapter-path or terminal in-band failure:
|
||||
'step/end'
|
||||
agent/request-error(original error, failure facts, immutable prior failures, signal)
|
||||
@@ -125,7 +125,7 @@ forever:
|
||||
|
||||
适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交任何内容。
|
||||
|
||||
其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
|
||||
其他故障使用 `agent/error`。取消和资源释放均优先于恢复;轮次信号还会在提交任何请求头之前取消异步模型能力准备,尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
|
||||
|
||||
会话事件均位于轮次边界内;重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。
|
||||
|
||||
|
||||
+16
-11
@@ -565,18 +565,19 @@ Requires: `llm`
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema. Every field
|
||||
* is optional in yml: credentials/endpoint fall back to the environment (a
|
||||
* missing API key fails plugin load, not the first call), and omitted
|
||||
* thinking fields send nothing on the wire, so the provider default applies.
|
||||
* missing API key fails plugin load, not the first call), omitted thinking
|
||||
* mode uses the provider default, and omitted reasoning effort resolves to
|
||||
* `high`.
|
||||
*/
|
||||
export interface Config {
|
||||
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
|
||||
baseURL?: string
|
||||
/** Thinking-mode default for every request (provider default: enabled). */
|
||||
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Thinking effort (only meaningful with thinking enabled). */
|
||||
reasoningEffort?: 'high' | 'max'
|
||||
/** Default thinking effort (default `high`); `off` disables thinking per request. */
|
||||
reasoningEffort?: 'off' | 'high' | 'max'
|
||||
/** Positive context capacity used when the selected model has no exact value. */
|
||||
defaultContextWindow?: number
|
||||
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
|
||||
@@ -585,7 +586,7 @@ export interface Config {
|
||||
streamIdleTimeoutMs?: number
|
||||
}
|
||||
|
||||
/** One optional model entry advertised by the hand-written adapter. */
|
||||
/** One optional model entry advertised by the direct-fetch adapter. */
|
||||
export interface DeepSeekCatalogModel {
|
||||
/** Wire model id accepted by the configured endpoint. */
|
||||
id: string
|
||||
@@ -598,7 +599,7 @@ export interface DeepSeekCatalogModel {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/llm/llm-deepseek/src/index.ts:34`](../packages/llm/llm-deepseek/src/index.ts)
|
||||
Source: [`packages/llm/llm-deepseek/src/index.ts:35`](../packages/llm/llm-deepseek/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-pi-ai`
|
||||
|
||||
@@ -622,7 +623,7 @@ export interface PiAiProviderProfile {
|
||||
/** Provider request headers; Harness attribution wins reserved names. */
|
||||
headers?: Record<string, string>
|
||||
/** Provider-neutral pi-ai reasoning level. */
|
||||
reasoning?: ThinkingLevel
|
||||
reasoning?: ModelThinkingLevel
|
||||
/** Token budgets used by reasoning providers that support them. */
|
||||
thinkingBudgets?: ThinkingBudgets
|
||||
/** Prompt-cache retention preference. */
|
||||
@@ -638,7 +639,7 @@ export interface PiAiProviderProfile {
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `ThinkingLevel` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
|
||||
Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
|
||||
|
||||
Source: [`packages/llm/llm-pi-ai/src/config.ts:48`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
|
||||
@@ -688,7 +689,7 @@ export interface ReplayModelConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/support/llm-replay/src/index.ts:590`](../packages/support/llm-replay/src/index.ts)
|
||||
Source: [`packages/support/llm-replay/src/index.ts:598`](../packages/support/llm-replay/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-retry`
|
||||
|
||||
@@ -1781,7 +1782,7 @@ export interface TuiConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts)
|
||||
Source: [`packages/ui/tui/src/index.ts:273`](../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tui-demo`
|
||||
|
||||
@@ -2047,6 +2048,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts))
|
||||
@@ -2054,6 +2056,9 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-slash` ([`packages/client/ui-slash/src/index.ts`](../packages/client/ui-slash/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
adding-an-llm-adapter.md: f20442b8c2ce823452a3ea13409f202185d12d04
|
||||
adding-an-llm-adapter.zh.md: 2864dd1e18742c7449e24f22504a5a38976ab450
|
||||
# pnpm run verify-translation-pairing --write docs/cookbook/adding-an-llm-adapter.md
|
||||
adding-an-llm-adapter.md: a7f9dced70041653a0cb815147a07b6386d79e3e
|
||||
adding-an-llm-adapter.zh.md: 3515927585201326b713bb03cd863886ce7846bd
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](adding-an-llm-adapter.zh.md)
|
||||
|
||||
How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against.
|
||||
How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (direct HTTP, SSE framed by `eventsource-parser`) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against.
|
||||
|
||||
## The shape
|
||||
|
||||
@@ -32,7 +32,7 @@ Registration is effect-based (HMR-safe); one adapter per provider route — dupl
|
||||
- A `GenerateOptions` field your provider cannot honor (e.g. a `stop` list on a provider without stop sequences): throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping it.
|
||||
- If the provider requires response ids, signatures, or other native metadata on follow-up calls, emit the minimal lossless-JSON projection as `finish.replayState`. Validate it when rebuilding history. `LlmService` passes it only when the historical provider route and target provider route are currently owned by the exact same adapter instance; your adapter decides whether same-model, cross-model, or cross-provider restoration is legal. Never infer native replay from provider/model names alone when state is absent.
|
||||
|
||||
Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral.
|
||||
Provider-specific thinking-mode toggles remain in the adapter's Config. Exact model metadata uses one provider-neutral capability seam: implement `resolveModel()` with provider/model identity and optional `context` and `reasoning` fields, declare a configured `defaultEffort` only when one exists, and honor the resolver's optional `AbortSignal`. Reasoning efforts are ordered opaque ids mapped to provider requests by the adapter. Preserve the adapter's authoritative selectable list, including an adapter-defined `off` when supported, without exposing final wire spellings or clamping unsupported values; an id need not equal its wire representation.
|
||||
|
||||
## Structure that worked
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](adding-an-llm-adapter.md) | 中文
|
||||
|
||||
如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(手写 HTTP/SSE)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。
|
||||
如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(直接 HTTP,SSE 由 `eventsource-parser` 分帧)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。
|
||||
|
||||
## 基本形态
|
||||
|
||||
@@ -32,7 +32,7 @@ export function apply(ctx: Context, config: Config) {
|
||||
- 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。
|
||||
- 如果提供方在后续调用中需要响应 ID、签名或其他原生元数据,请将其最小无损 JSON 投影作为 `finish.replayState` 发出。重建历史时验证该状态。只有历史提供方路由和目标提供方路由当前由完全相同的适配器实例拥有时,`LlmService` 才会传递该状态;由适配器决定同模型、跨模型或跨提供方恢复是否合法。状态缺失时,切勿仅根据提供方/模型名称推断原生回放。
|
||||
|
||||
提供方特有的请求旋钮(thinking 模式、effort 级别)放在**适配器**的 Config 中,而非 `GenerateOptions` 中——核心词汇保持提供方无关。
|
||||
提供方特有的 thinking 模式开关仍放在适配器的 Config 中。确切模型元数据使用一处提供方无关的能力 seam:实现 `resolveModel()`,返回提供方/模型身份以及可选的 `context` 和 `reasoning` 字段;仅当存在配置指定的默认值时才声明 `defaultEffort`;响应传给解析器的可选 `AbortSignal`。推理强度是由适配器映射到提供方请求的有序不透明 ID。请保留适配器给出的权威可选列表,包括适配器在支持时定义的 `off`;不得暴露最终协议值的具体拼写,也不得自动调整不支持的值。ID 无需与其协议表示相同。
|
||||
|
||||
## 经验证有效的结构
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie
|
||||
|
||||
The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).
|
||||
|
||||
Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).
|
||||
Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`), **bail** (synchronous in-order dispatch until one listener returns a bail value; the scoped input-mutation events use it for an applied/not-applied answer).
|
||||
|
||||
## `agent/*`
|
||||
|
||||
@@ -616,7 +616,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
|
||||
|
||||
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/llm/llm/src/index.ts:52`](../../packages/llm/llm/src/index.ts)
|
||||
Source: [`packages/llm/llm/src/index.ts:53`](../../packages/llm/llm/src/index.ts)
|
||||
|
||||
## `session/*`
|
||||
|
||||
@@ -708,6 +708,75 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `slash/*`
|
||||
|
||||
### `slash/input-begin-command` — bail
|
||||
|
||||
Applies one command claim to the scoped Input. Dispatched with the session's scope carrier; the owning session's input listener returns `true` only after the phase and span CAS checks pass and the machine actually mutated — producers treat anything else as "not applied".
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Applies one command claim to the scoped Input. Dispatched with the
|
||||
* session's scope carrier; the owning session's input listener returns
|
||||
* `true` only after the phase and span CAS checks pass and the machine
|
||||
* actually mutated — producers treat anything else as "not applied".
|
||||
* @param request - Claim and menu-time span CAS.
|
||||
* @mode bail
|
||||
*/
|
||||
'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
### `slash/input-consume-token` — bail
|
||||
|
||||
Consumes one command token after business success (popup settle / menu-pick execute). Same carrier routing and applied-truth contract.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Consumes one command token after business success (popup settle /
|
||||
* menu-pick execute). Same carrier routing and applied-truth contract.
|
||||
* @param request - Exact span or bare-token guard.
|
||||
* @mode bail
|
||||
*/
|
||||
'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
### `slash/input-insert-reference` — bail
|
||||
|
||||
Inserts one reference into the scoped Input (same carrier routing and applied-truth contract as begin-command).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Inserts one reference into the scoped Input (same carrier routing and
|
||||
* applied-truth contract as begin-command).
|
||||
* @param request - Reference and menu-time span CAS.
|
||||
* @mode bail
|
||||
*/
|
||||
'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
### `slash/input-insert-text` — bail
|
||||
|
||||
Replaces the trigger token span with literal text — the plain-text reference path (decision 21). Same carrier routing and applied-truth contract; the draft gains ordinary characters, no occurrence entry.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Replaces the trigger token span with literal text — the plain-text
|
||||
* reference path (decision 21). Same carrier routing and applied-truth
|
||||
* contract; the draft gains ordinary characters, no occurrence entry.
|
||||
* @param request - Replacement text and menu-time span CAS.
|
||||
* @mode bail
|
||||
*/
|
||||
'slash/input-insert-text'(request: InsertTextRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
## `subagent/*`
|
||||
|
||||
### `subagent/end` — emit
|
||||
|
||||
@@ -730,33 +730,57 @@ listProviders(): LlmProviderInfo[]
|
||||
async listModels(provider: string): Promise<LlmModelInfo[]>
|
||||
|
||||
/**
|
||||
* Resolve context capacity from the adapter that owns one exact route.
|
||||
* This query is independent of the advisory model catalog: an unlisted model
|
||||
* may return metadata, while `undefined` never rejects later routing.
|
||||
* Resolve and validate all metadata from the adapter that owns one exact
|
||||
* route. The result is detached from adapter-owned objects; catalog
|
||||
* membership remains advisory and does not control request routing.
|
||||
* @param provider - registered provider route to inspect.
|
||||
* @param model - exact model id passed to the adapter.
|
||||
* @returns detached context metadata, or `undefined` when the adapter has none.
|
||||
* @param signal - optional cancellation for adapter-owned asynchronous lookup.
|
||||
* @returns exact model identity plus available context and reasoning metadata.
|
||||
*/
|
||||
async resolveModelContext( provider: string, model: string, ): Promise<LlmModelContext | undefined>
|
||||
async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ): Promise<LlmResolvedModelInfo>
|
||||
|
||||
/**
|
||||
* Validate a conversation call config against its exact model capability and
|
||||
* materialize an adapter-configured default. Unsupported explicit efforts
|
||||
* reject before provider I/O; no clamping or aliasing is performed. This
|
||||
* standalone query does not bind a later dispatch; use {@link prepareCall}
|
||||
* when logging and streaming must share one adapter registration.
|
||||
* @param config - provider/model route and optional request controls.
|
||||
* @param signal - optional cancellation for adapter-owned capability lookup.
|
||||
* @returns a detached config only when a default must be materialized.
|
||||
*/
|
||||
async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>
|
||||
|
||||
/**
|
||||
* Resolve one call under its current adapter registration. The returned
|
||||
* one-shot handle keeps that registration across header logging and dispatch,
|
||||
* so HMR cannot combine one adapter's capability result with another adapter.
|
||||
* @param config - provider/model route and optional request controls.
|
||||
* @param signal - optional cancellation for adapter-owned capability lookup.
|
||||
* @returns a prepared config and its registration-bound stream entry point.
|
||||
*/
|
||||
async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>
|
||||
|
||||
/**
|
||||
* Stream one model call as raw chunks (token-level deltas). Throws
|
||||
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
|
||||
* `options.provider`. Replay state is retained only when the same adapter
|
||||
* instance owns its historical provider and the target provider. Final
|
||||
* adapter selection, dispatch, and iteration failures retain their original
|
||||
* Error identity and are tagged in a call-local scope for narrow agent-loop
|
||||
* request recovery; middleware and nested-call failures remain untagged for
|
||||
* the outer call.
|
||||
* adapter selection remains fixed through asynchronous exact-model resolution
|
||||
* and dispatch. Selection, dispatch, and iteration failures retain their
|
||||
* original Error identity and are tagged in a call-local scope for narrow
|
||||
* agent-loop request recovery; middleware and nested-call failures remain
|
||||
* untagged for the outer call.
|
||||
* @param options - the full request; `options.provider` selects the adapter.
|
||||
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
|
||||
*/
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
||||
```
|
||||
|
||||
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
|
||||
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/llm/llm/src/index.ts:159`](../../packages/llm/llm/src/index.ts)
|
||||
Source: [`packages/llm/llm/src/index.ts:177`](../../packages/llm/llm/src/index.ts)
|
||||
|
||||
## `ctx.permission` — `PermissionService`
|
||||
|
||||
@@ -1342,7 +1366,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
|
||||
|
||||
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:611`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.sessionTitle` — `SessionTitleService`
|
||||
|
||||
@@ -1878,7 +1902,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
|
||||
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:150`](../../packages/ui/tui/src/index.ts)
|
||||
Source: [`packages/ui/tui/src/index.ts:153`](../../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
core.md: 781267cccdb5bbda33e5be6a9e807fdbe47dbc83
|
||||
core.zh.md: d0f67983b98b0cf679a8e599a5f8ab3c64490dd0
|
||||
core.md: 86aea325f5401d728a9b4aa147d78db9163a32c1
|
||||
core.zh.md: d09fb48419c14959eefb5a1df1c593b36c188cf5
|
||||
Loaded 100 of 457 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user