diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index f1d2fe1a90..dc88be13a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md index 5c3308b281..b922891d44 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 93b084973b..d98b57a0a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -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 论证退役其中一个适配器。 diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml index 6eeebc9848..cb487f67a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md index b637ba24d4..b00c744e4a 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md index 084e762ec2..88e26aa7a9 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md @@ -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 计量保持模型无关 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml index 6de82d1c9b..2cba925d67 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index a420c5945d..80c2688b15 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -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: '', 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 `..`, 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: '', 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 `..`, 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. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index 47c1f392f5..928c5f445d 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -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: '', 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: '', 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` 选项。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml new file mode 100644 index 0000000000..e9adb2cd8b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md new file mode 100644 index 0000000000..cc66e4ec15 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md new file mode 100644 index 0000000000..e0d28e1aca --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md @@ -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(热模块替换)期间的注册所有权和取消提供回归保障;可运行快照锁定实际组装请求头中的已解析推理强度,仅在有密钥时运行的适配器测试则覆盖提供方序列化。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml new file mode 100644 index 0000000000..529e319ee0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md new file mode 100644 index 0000000000..063494b564 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -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` (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` 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). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md new file mode 100644 index 0000000000..cd5d29dfbc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -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`(归属 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` 选择器 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` 三件套现成,等独立分支)。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml similarity index 60% rename from .agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index ced514a423..d636aab9ff 100644 --- a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml @@ -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-turndown-for-tool-web-html-markdown.md: 7f25e51bf6e6fc9313a880abee737bca80a472af -2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 3a59b08e13fd392e4f34ac543f32f5b4648f3c1c +2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b +2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md new file mode 100644 index 0000000000..5188e8c17b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md new file mode 100644 index 0000000000..0134cc10cf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md @@ -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 可配置性入台账待触发。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml new file mode 100644 index 0000000000..0249baff80 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md new file mode 100644 index 0000000000..acbd132a5f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -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 `` serialization); on the bubble side MessageItem decorates both shapes (the legacy `` 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. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md new file mode 100644 index 0000000000..158650a41b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -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);编辑破坏匹配形状下次扫描自然消失。 +- 发送即原文(不再 `` 序列化);气泡侧 MessageItem 双形状装饰(legacy `` 标签 + 纯文本 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 引用的模型表示待业务立项。 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index d7babf16b3..be023bf4c7 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md index df1bee2801..760373d64f 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md index 7fa5cb2aad..fd29e6049e 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -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 事件 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 6437d7813d..ee6efc69f4 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -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-06-sandbox.md: 723ef170188dc11da24e049a1e2838fb240d0a17 -2026-07-06-sandbox.zh.md: a8c7743bb3d499fb58f507ea2c202b44efe2311d +2026-07-06-sandbox.md: c6883873192f15ba2982436e156d8795396c0148 +2026-07-06-sandbox.zh.md: d84df9b06b15dd296801073d381603f34cfd2878 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 723ef17018..c688387319 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index a8c7743bb3..d84df9b06b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——对安全不变式而言,它还远未经过实战检验。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index f7e242b78c..4c73d590af 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 148d4a2f47..43c87bb159 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 9a9d9cd4b0..8afc210344 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -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 验证。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 79a4f5e7cc..6d44658ecc 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index aac67ffec8..8d7c7b00c8 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 0f64e2ce14..49e9541c9f 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -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 中增加工具专用分支。 -- 模型选择使用适配器提供的目录元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 +- 模型和推理强度选择使用适配器公布的元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 1ff9ecac7d..1f52492649 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index ce98a16fa4..38d193271d 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index 0d6fd2f9e6..be3cd041c6 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -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 表层仍暂缓。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml new file mode 100644 index 0000000000..5c8530da70 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -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: 5fe08cc40c1d23ff3a9b8c6d766fea6d3694c30d +2026-07-23-web-todo-display.zh.md: c121ffc27e3d0a93707c2c22b2f180023ebae5be diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md new file mode 100644 index 0000000000..5fe08cc40c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -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 to a header of title + `"/ tasks · in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. + +### TodoRow: the per-call row through the keyed toolview slot + +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. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md new file mode 100644 index 0000000000..c121ffc27e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -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` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 + +### TodoRow:经 keyed toolview slot 的逐调用行 + +专用的 `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 姿势)——因此重开会话时即使最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,而尾页响应不带投影时复位为空。 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index 3295a845f3..b8266cdd49 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b -2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +2026-07-25-workspace-ui-product-flow.md: b8e1ec1efe19127cad8a12405dddeec38a4ff91e +2026-07-25-workspace-ui-product-flow.zh.md: b80b75a80671e9aa2ab59ff72c44c18a8ec5c16e diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index a02087235a..b8e1ec1efe 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -21,10 +21,11 @@ The Host provides the following GUI wiring on the Workspace entity: | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | | `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | | `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path | +| `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | -`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. +`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. @@ -51,7 +52,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Rename, Delete, moving across Workspaces, drag-and-drop ordering, manual adoption from Ungrouped, and separate display-name and directory-name inputs are outside this iteration's scope. +Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery @@ -75,6 +76,8 @@ A frontend Session Intent appears as a “New session” row and temporarily cou Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. +Deleting a Workspace registration removes its group without deleting or closing any Session. Its accounted Sessions immediately join Ungrouped, including the current Session; a reload reconstructs the same result from the independent Workspace and Session baselines. + ### React and slot boundaries React components only consume `useSessions`, `useWorkspaces`, and session-scoped hooks; they do not own entity lifecycles. The Zustand store retains only layout, the current view, composer text for ordinary real Sessions, and other purely presentational state. Session and Workspace Intents, materialization phases, errors, and retained prompts reside in the React-free runtime object layer. @@ -106,6 +109,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo - The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. - A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. - Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 8ccbf5b984..b80b75a806 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -21,10 +21,11 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | | `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | | `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 | +| `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。 +`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 @@ -51,7 +52,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。Rename、Delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编和显示名/目录名双输入不在本期范围。 +Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 @@ -75,6 +76,8 @@ Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定 无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 +删除 Workspace 注册记录会移除其分组,但不会删除或关闭任何 Session。已记账的 Session(包括当前 Session)会立即进入 Ungrouped;刷新后,独立的 Workspace 与 Session 基线会重建出相同结果。 + ### React 与 slot 边界 React 组件只消费 `useSessions`、`useWorkspaces` 与 session-scoped hooks,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。 @@ -106,6 +109,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 - 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 - UI 与 Host 两层拒绝同名 Workspace;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml new file mode 100644 index 0000000000..e664ec81fb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md new file mode 100644 index 0000000000..869e7a2518 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md new file mode 100644 index 0000000000..353e5ac765 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-user-message-icon-actions.zh.md @@ -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 的排除。 diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml new file mode 100644 index 0000000000..d576fb10e5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md +2026-07-27-workspace-registration-deletion.md: 8168b0832ca39e6023f6981815ffe758b5695361 +2026-07-27-workspace-registration-deletion.zh.md: b0df6982ac81426a5b0ce2f0e2b0e744212e3f5b diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md new file mode 100644 index 0000000000..8168b0832c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md @@ -0,0 +1,59 @@ +# Agent Note: Workspace Registration Deletion + +Status: implemented + +English | [中文](2026-07-27-workspace-registration-deletion.zh.md) + +## Problem + +A Workspace registers an existing code directory so the GUI can name it and order its Sessions. That record has no reliable provenance proving that Harness created or owns the directory, and the Session log is an independent persistence object. Treating the row's Delete action as recursive source deletion or Session deletion would destroy data outside the record's ownership boundary. + +The existing visual-only menu row also left deletion semantics undefined across durable order, the Workspace table, Host streams, concurrent browser tabs, reconnect baselines, and a list request racing the mutation. + +## Decision + +`ctx.workspace.delete(id)` deletes only the Workspace registration: its id leaves durable `workspaceIds`, its `workspaces` table row and entity-cache entry disappear, and its ordered `sessionIds` account disappears with that row. It never calls filesystem removal or `SessionPersistence`; the directory, every user file, every live Session, and every persisted Session log remain. Because sidebar grouping is the complement of all surviving Workspace accounts, those Sessions immediately appear under Ungrouped, including the current Session. + +Unknown ids return `false` at the domain seam. `workspace.delete({ workspaceId })` maps that distinction to `workspace-not-found`; success returns `{ deleted: true }`. `workspace.list` remains the reconnect baseline. + +## Durable commit and publication + +Registry operations serialize create and delete. Deletion first writes the Workspace order without the id, then removes the entity from the cache, then deletes the table row. The table deletion is the notification commit point: the package invariant accepts it only after the cache stopped publishing the entity, and the Host emits `host/workspace-removed` only from that committed deletion. A table-write failure restores the cache and prior durable order; no removal frame is published. + +The Host stream keeps its committed-id set through the preceding global-order write and removes the id only on the table deletion. Create rollback therefore emits no false removal, while every connected tab receives exactly the id needed to delete its projection. + +Create and delete write a durable `pendingMutation` before their record/order pair can diverge. Startup completes only the named create or delete and clears the marker; it never infers crash provenance from an orphan row alone. Unmarked order/table divergence therefore retains the registry's fail-loud corruption behavior. A deletion whose table write committed but marker cleanup failed still reports success—the requested state and removal frame are already committed—and the next startup clears that marker idempotently. + +## Client convergence + +`WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta. + +The delete confirmation remains pending until the React Workspace projection has committed the removed id, so the next create gesture cannot observe one stale list frame. During create, duplicate-name validation is suppressed while the request is pending because the committed `host/workspace-changed` frame may publish the newly created Workspace before its unary response; after failure returns the form to editing, validation uses the latest list again. + +## Confirmation interaction + +The existing Workspace row menu opens a shared `Modal` before deletion. The text states all three consequences: the Workspace leaves the list, the folder and session logs remain, and its Sessions appear under Ungrouped. While the request is pending, the confirm and Cancel controls are disabled, duplicate confirmation is ignored, and Escape or Close cannot dismiss the operation. Failure keeps the Modal open with the error; Cancel, Escape, and Close before submission never delete. + +The menu, Modal, and buttons retain their existing structure and design tokens. Session deletion remains visual-only and outside this decision. + +## Alternatives considered + +**Cascade-delete Sessions.** Rejected because Workspace registration does not own Session persistence and the product requirement is to preserve histories under Ungrouped. Session deletion needs its own lifecycle, running checks, descendant semantics, and explicit UI. + +**Move the folder to Trash.** Rejected because the record cannot prove directory ownership. A future destructive filesystem action must be separately named, separately confirmed, and enforce explicit safety boundaries. + +**Delete the table row and repair order later.** Rejected because a crash or write failure would leave an initialized registry whose order and table disagree. The registry updates both under one serialized operation and restores the prior order on table failure. + +**Delete every unreferenced row at startup.** Rejected because the same shape can come from unexplained order corruption; silently discarding it could lose Workspace metadata and Session accounting. Recovery requires the explicit pending marker written by the owning mutation. + +**Refetch both lists after success.** Rejected because the committed removal frame plus immediate unary echo is sufficient, preserves the current Session object, and avoids turning a local mutation into two list requests. Reconnect baselines remain the repair path. + +## Verification + +Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, projection-settled closing, pending-state duplicate suppression, success-frame-before-unary ordering, failure, Cancel, Escape, and Close. The browser scenario observes every transient alert, slot error, console error, and page error while reusing a deleted title for a different directory. + +The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload. + +## Consequences + +Deleting a Workspace is intentionally reversible by registering the same directory again with a fresh id, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns. diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md new file mode 100644 index 0000000000..b0df6982ac --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md @@ -0,0 +1,59 @@ +# Agent Note(agent 决策记录):删除 Workspace 注册记录 + +Status: implemented + +[English](2026-07-27-workspace-registration-deletion.md) | 中文 + +## Problem + +Workspace 注册已有代码目录,使 GUI 能够为目录命名,并对其会话排序。该记录没有可靠的来源信息来证明 Harness 创建或拥有该目录,会话日志也是独立的持久化对象。若将行内 Delete 操作视为递归删除源码或删除会话,就会破坏该记录所有权边界之外的数据。 + +现有菜单行仅提供视觉效果,因此持久顺序、Workspace 表、Host 流、并发浏览器标签页、重连基线,以及列表请求与变更并发时的删除语义也没有定义。 + +## Decision + +`ctx.workspace.delete(id)` 只删除 Workspace 注册记录:其 id 会从持久 `workspaceIds` 中移除,`workspaces` 表行与实体缓存条目会消失,有序 `sessionIds` 账本也随该行一并消失。它绝不调用文件系统移除操作或 `SessionPersistence`;目录、所有用户文件、所有实时会话和所有持久化会话日志都会保留。侧边栏分组是所有存续 Workspace 账本的补集,因此这些会话(包括当前会话)会立即出现在 Ungrouped 下。 + +未知 id 在 domain seam 返回 `false`。`workspace.delete({ workspaceId })` 将该结果映射为 `workspace-not-found`;成功时返回 `{ deleted: true }`。`workspace.list` 仍是重连基线。 + +## 持久提交与发布 + +注册表操作会串行执行创建与删除。删除时先写入移除该 id 后的 Workspace 顺序,再从缓存中移除实体,最后删除表行。表删除是通知提交点:只有缓存停止发布该实体后,包不变量才接受该删除;Host 也只根据这次已提交的删除发出 `host/workspace-removed`。表写入失败时,系统会恢复缓存和此前的持久顺序,且不会发布移除帧。 + +Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合,只在删除表行时移除该 id。因此,创建回滚不会发出错误的移除帧,而每个已连接标签页都能收到从自身投影中删除该记录所需的准确 id。 + +Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendingMutation`。启动时只补全其中明确命名的 create 或 delete,并清除该标记;系统绝不会仅凭孤立表行的形状推断崩溃来源。因此,没有标记的顺序/表分叉仍会保持注册表原有的损坏直接失败语义。如果删除的表写入已经提交、但标记清理失败,操作仍会报告成功——请求状态和移除帧都已经提交——下一次启动会以幂等方式清除该标记。 + +## 客户端收敛 + +`WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。 + +删除确认框会保持待处理,直到 React Workspace 投影已经提交目标 id 的移除,因此下一次创建操作不会读到一帧陈旧列表。创建请求进行中会暂停重复名称校验,因为已提交的 `host/workspace-changed` 帧可能先于一元响应发布刚创建的 Workspace;如果请求失败并让表单回到可编辑状态,系统会重新使用最新列表执行校验。 + +## 确认交互 + +现有 Workspace 行菜单会在删除前打开共享 `Modal`。文案明确说明三项后果:Workspace 会从列表中移除,文件夹和会话日志会保留,相关会话会出现在 Ungrouped 下。请求待处理期间,确认与 Cancel 控件均被禁用,重复确认会被忽略,Escape 或 Close 也无法关闭此次操作。失败时 `Modal` 保持打开并显示错误;提交前使用 Cancel、Escape 或 Close 绝不会触发删除。 + +菜单、`Modal` 和按钮保留现有结构与设计 token。会话删除仍仅提供视觉效果,不在本决策范围内。 + +## Alternatives considered + +**级联删除会话。** 不予采纳,因为 Workspace 注册记录不拥有会话持久化,且产品需求是将历史记录保留在 Ungrouped 下。会话删除需要自己的生命周期、运行状态检查、后代对象的处理语义和明确 UI。 + +**将文件夹移到废纸篓。** 不予采纳,因为该记录无法证明目录所有权。未来的破坏性文件系统操作必须使用单独名称、单独确认,并实施明确的安全边界。 + +**先删除表行,之后再修复顺序。** 不予采纳,因为崩溃或写入失败会使已初始化注册表的顺序与表不一致。注册表会在同一串行操作内更新二者,并在表操作失败时恢复此前顺序。 + +**启动时删除所有未引用表行。** 不予采纳,因为来源不明的顺序损坏也会呈现相同形状;静默丢弃可能损失 Workspace 元数据和 Session 账本。恢复必须依赖拥有该变更的操作预先写入的明确待处理标记。 + +**成功后重新拉取两个列表。** 不予采纳,因为已提交的移除帧与即时一元回显已足够,既能保留当前会话对象,也避免将局部变更扩大为两次列表请求。重连基线仍是修复路径。 + +## Verification + +Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、投影稳定后关闭、待处理状态下抑制重复提交、成功帧先于一元响应、失败、Cancel、Escape 与 Close。浏览器场景会在为不同目录复用已删除名称时,观测每一次瞬时 alert、slot error、console error 与 page error。 + +组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。 + +## Consequences + +删除 Workspace 后仍可使用新 id 重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index 3019c21fbb..d3ee2b2a03 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index bebff600ca..8e9d0ab865 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -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 `, 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 ` 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. diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 66c9b75b1b..cf37d56be0 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -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 `,要求点名所确认的配对;批量重新记录是显式的 `--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 ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。 +- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 17edb300cc..51073f506e 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -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-21-serial-cross-platform-ci-reference.md: 5433d2c51831ce61d06a16ee0b0ed982911f9218 -2026-07-21-serial-cross-platform-ci-reference.zh.md: 041d53d13e14354c995e4b65defce94a97646b0a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +2026-07-21-serial-cross-platform-ci-reference.md: 9ecd7906d2f28461a8a6f3f2fe485580214b39f3 +2026-07-21-serial-cross-platform-ci-reference.zh.md: ed927012c5b0ba9d43444a129d10d966f5e7d923 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 5433d2c518..9ecd7906d2 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -14,17 +14,17 @@ Reviewers also need a direct answer to a simpler question: what happens when the ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. -Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. +Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. Platform ownership remains explicit inside that complete aggregate. `pty-local` supports Linux and macOS and therefore owns its unit and per-file coverage contract on POSIX rather than loading a backend that rejects `win32`; the Windows run still executes every portable package. Portable fixtures derive native paths through `node:path`, compare canonical identities with the same native realpath implementation as production, and use filenames legal on every host. ACP snapshot runs also pass both JavaScript and native realpath spellings of their generated cwd to the normalizer, which replaces aliases longest-first so Windows short and long paths cannot churn shared fixtures. The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install. -Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. +Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 041d53d13e..ed927012c5 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -14,17 +14,17 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 -每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 +每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 该完整聚合流程仍明确划分平台归属。`pty-local` 支持 Linux 与 macOS,因此其单元测试和逐文件覆盖率契约由 POSIX 平台负责,而不会在 Windows 上加载一个明确拒绝 `win32` 的后端;Windows 仍会执行所有可移植包(package)。可移植 fixture(测试前置数据)通过 `node:path` 派生原生路径,使用与生产代码相同的原生 realpath 实现比较规范化后的路径标识,并采用所有宿主机均允许的文件名。ACP(Agent Client Protocol)快照运行还会把生成的 cwd 分别通过 realpath 的 JavaScript 实现与原生实现得到的两种表示一并传给规范化器;规范化器按长度从长到短替换这些别名,避免 Windows 的短路径与长路径表示差异导致共享 fixture 反复变化。 macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 -master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 +master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 3d8e6fc395..68b4098d4f 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -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-22-evidence-based-larger-hosted-runners.md: fe11e6929545923d27fbf41f5a39f7dd2b9c3fbf -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 47879284532a537cbe7e78aa2c495c4ef0be26c4 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +2026-07-22-evidence-based-larger-hosted-runners.md: 67fc7ded5cffc6a219665f135a4c9e1cc4752691 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 71c5c067361b57fab5aae9e9ffa3850a30609db3 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index fe11e69295..67fc7ded5c 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -52,6 +52,8 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records. + ## Alternatives considered **Keep the three coarse primary Linux lanes.** The core, CPU, and production-site jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 4787928453..71c5c06736 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -52,6 +52,8 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 + ## 曾考虑的替代方案 **保留 3 个粗粒度 Linux 主流程通道。** 核心、CPU 和生产网站作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 05147cd54a..66131cfe0c 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml @@ -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-23-portable-required-pull-request-ci.md: d1002c7d9db7cd8bbed3bdfda8a773a4b124bf16 -2026-07-23-portable-required-pull-request-ci.zh.md: fedfc6b9c982ace5ece430c52db23c22ec5119d4 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +2026-07-23-portable-required-pull-request-ci.md: 1a6939e8386e381cba114a7be71993a644457a45 +2026-07-23-portable-required-pull-request-ci.zh.md: cf0af769f9e740a2c9285caf4be05023371578d9 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index d1002c7d9d..1a6939e838 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,9 +12,9 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs on standard `windows-2025` with single-worker bounds, keeping the complete Windows contract independent of enterprise Windows allocation. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)), keeping the pull-request Windows contract independent of any Windows runner allocation; the complete native-kernel Windows inventory lives in the master serial reference. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. -The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. +The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. @@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) ## Consequences -Ordinary pull requests spend enterprise capacity on the Linux critical path while standard Windows trades longer runtime for independent allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. +Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine-hosted Windows job keeps its verdict on standard Linux allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index fedfc6b9c9..cf0af769f9 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `windows-2025` 上运行,并采用单工作线程上限,使完整的 Windows 契约不依赖企业级 Windows 运行器分配。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node 以覆盖阻断表面([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)),使拉取请求的 Windows 契约不依赖任何 Windows 运行器分配;完整的原生内核 Windows 清单归 master 串行参考流程所有。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 -两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 +两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而标准托管 Windows 作业则以更长的运行时间换取不依赖企业池的运行器分配。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 +普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 托管的 Windows 作业让其判定保持在标准 Linux 运行器分配上。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 企业级运行器分配能力下降时,标准兼容性作业和必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml new file mode 100644 index 0000000000..e90e257c46 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md new file mode 100644 index 0000000000..63f25d5c36 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md @@ -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 `, `--write --all`, `--list` exclusivity, unknown flags. diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md new file mode 100644 index 0000000000..17cf2f895b --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -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 `、`--write --all`、`--list` 的互斥性、未知标志。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml new file mode 100644 index 0000000000..a7b9e827fa --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -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-ci-failover-runbook.md +2026-07-26-ci-failover-runbook.md: 4e4f8ea7fc60cf76fd8308147bbf7cc0bac74798 +2026-07-26-ci-failover-runbook.zh.md: bb7e43fe55c9cced51f042de6503978ec9349d6b diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md new file mode 100644 index 0000000000..4e4f8ea7fc --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -0,0 +1,50 @@ +# Agent Note: CI failover runbook — hosted pools → in-house pool + +Status: implemented + +English | [中文](2026-07-26-ci-failover-runbook.zh.md) + +## Problem + +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: this switch recovers an enterprise Linux-pool outage.** The verdict's other required dependencies (`node-compat`, `python-sdk`, `windows`) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`, and only the Windows leg has no in-house substitute at all — during the 2026-07-27 outage the standard pools recovered first, which is the ordering this design bets on. An outage therefore needs a switch any responder with repository write access can throw without merging anything. + +## Decision + +Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. + +### What the in-house pool is + +`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. + +### Switch (any repository writer, ~1 minute, no merge) + +1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. +2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. +3. That is the entire switch. Under failover the workflow also, automatically: drops `DSH_COVERAGE_MAX_WORKERS` to 8 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 (sized for six always-on instances: worst case 6 × 8 = 48 coverage workers on the 64-core VM) (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). + +#**Dependabot exception.** All four selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VM. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. + +**Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so the switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner group admits all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VM by pushing a branch workflow. The boundary against untrusted code is repository membership; the variable only routes work for members. + +## Capacity during failover + +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; only a started service adds capacity. About a minute per instance. + + +### Switch back + +Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Remove any extra instances that were registered during the incident. + +### Trust boundary + +The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the four failover jobs are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access. + +## Alternatives considered + +**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is writer-manageable state that takes effect on re-run without a merge. + +**Keep the self-hosted pool always in the required path.** Rejected because it trades hosted-pool availability for the in-house VM's, moving a single point of failure rather than adding a fallback. The variable keeps the hosted pools primary and the self-hosted pool a proven, one-action standby. + +## Consequences + +Recovering from a hosted-pool outage is a single variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md new file mode 100644 index 0000000000..bb7e43fe55 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -0,0 +1,50 @@ +# Agent Note: CI 故障切换手册 — 托管池 → 自有池 + +Status: implemented + +[English](2026-07-26-ci-failover-runbook.md) | 中文 + +## 问题 + +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:本切换恢复的是企业级 Linux 池故障。**判定作业的其余必需依赖(`node-compat`、`python-sdk`、`windows`)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`,且只有 Windows 这条腿完全没有自有替代——2026-07-27 的故障中标准池率先恢复,本设计押注的正是这一顺序。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 + +## 决策 + +三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 + +### 自有池是什么 + +`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 + +### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) + +1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 +2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 +3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏 6 × 8 = 48 个覆盖率工作进程对 64 核)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 + +#**Dependabot 例外。**四个选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖方提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。 + +**谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此该开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成越权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。 + +## 切换期间的容量 + +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;只有启动了服务的 runner 才会增加容量。每个约一分钟。 + + +### 切回 + +删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若故障期间追加注册过实例,将其移除。 + +### 信任边界 + +该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——四个故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。 + +## 曾考虑的替代方案 + +**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是写者可管理的状态,重跑即生效,无需合并。 + +**让自托管池长期处于必需路径中。** 否决,因为这是拿托管池的可用性去换自有虚拟机的可用性,只是搬移了单点故障而非增加回退。该变量让托管池保持主路径,自托管池作为一个经过验证、一步即可启用的热备。 + +## 后果 + +从托管池故障中恢复只需一个变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml new file mode 100644 index 0000000000..8b8b736a99 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml @@ -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-27-wine-windows-gates-experiment.md +2026-07-27-wine-windows-gates-experiment.md: aab8aecdfca06c1f15641044a071015f543a84b6 +2026-07-27-wine-windows-gates-experiment.zh.md: 5239b185e1e0c63aa626ee3f20f3f298c0c8579d diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md new file mode 100644 index 0000000000..aab8aecdfc --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -0,0 +1,45 @@ +# Agent Note: Wine-run Windows blocking gates on Linux runners + +Status: implemented + +English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md) + +## Problem + +The pull-request Windows lane exists to prove the two blocking win32 surfaces — the workspace build and the production site — and it ran on hosted `windows-2025`, the slowest job in the required matrix: 7–9 minutes against 1.5–2.5 for the Linux jobs, so the Windows VM's boot, setup, and filesystem costs dominated every pull request's critical path. + +The question the experiment answered: can a plain Linux runner produce an equivalent win32 signal for the blocking surfaces at Linux wall clock, so no Windows VM sits on the pull-request path at all? + +## Decision + +The required pull-request `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) (`windows node 24 / wine blocking`) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a checksum-verified win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. The master `serial-windows` job is untouched: the complete native-kernel inventory, including the observational portability gates this lane does not run, still executes on real `windows-2025` on every master push. + +Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: the independent prototype in [PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; #689's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). + +The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope. + +Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate). + +## Measured results + +Measured on 2026-07-27, warm caches, pull-request trigger, standard 2-core `ubuntu-latest`: 2m46s end-to-end — setup and cache restores ~17s, concurrent install+provision 33s, concurrent gates 110s — against 1.5–2.5 minutes for the Linux CI jobs and 7–9 minutes for the replaced `windows-2025` job. A cold-cache run pays roughly one extra minute. An 8-core benchmark leg was defined during the experiment but never left the restricted `dsh-ubuntu-*` pool's queue; the standard-runner number met the target, so no larger box is used. + +## Alternatives considered + +**Keep the hosted `windows-2025` pull-request job (status quo).** Nothing wrong with its signal, only its latency: 7–9 minutes for two build commands, the slowest required job in the matrix. It survives as the master serial reference, where completeness matters more than latency. + +**A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs (40m19s measured end-to-end on the sibling experiment branch `exp/kvm-windows-ci`). Promotable only with disk-image caching that pressures the Actions cache budget. + +**Windows pnpm performing the install under Wine ([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689)).** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. + +**Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`; complementary to, not competitive with, this lane. + +**Windows containers.** Not possible: Windows containers require a Windows host kernel; a hosted Linux runner cannot run them. + +**Dropping the Windows lane.** Rejected — win32 is a first-class product target: the koffi-backed DACL and durable-namespace modules, ConPTY-based PTY sessions, and Windows path policy all ship in `packages/`. + +## Consequences + +Every pull request's Windows verdict now arrives in Linux-job time on free standard capacity, and no Windows VM allocation sits on the pull-request critical path; `all checks passed` consumes the same `windows` job id it always did. + +What the trade costs: Wine reimplements Win32 over a case-sensitive ext4 — NTFS case-insensitivity, real DACLs, ConPTY, and crash-durability semantics are not proved here, and the observational portability inventory (duplication, publint, node-next types, built-package invariants on win32) no longer runs on pull requests at all. The master `serial-windows` reference owns all of that: a Wine-green pull request can still fail the native-kernel master run, and that failure mode is accepted as post-merge. The lane also inherits Wine-specific divergences as permanent job structure — file-routed stdio, the host-side `vue` link, the hoisted layout — so a future toolchain change that depends on isolated-layout semantics or in-process symlink creation will surface here first as a Wine failure rather than a product failure, and triage must classify it as such. If Wine reds ever recur without product cause, the recorded fallback is reverting the `windows` job to the pre-Wine `windows-2025` definition preserved in git history. diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md new file mode 100644 index 0000000000..5239b185e1 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 在 Linux runner 上用 Wine 运行 Windows 阻断门禁 + +Status: implemented + +[English](2026-07-27-wine-windows-gates-experiment.md) | 中文 + +## 问题 + +Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表面——workspace 构建与生产站点——它此前运行在托管 `windows-2025` 上,是必需矩阵中最慢的作业:7–9 分钟,对照 Linux 作业的 1.5–2.5 分钟,因此 Windows VM 的启动、准备与文件系统开销主导了每个 pull request 的关键路径。 + +实验回答的问题是:一台普通 Linux runner 能否以 Linux 墙钟为阻断表面产出等效的 win32 信号,让 pull request 路径上完全没有 Windows VM? + +## 决策 + +[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。 + +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其"Windows pnpm 安装依赖树"的目标(安装契约在此仍由 Linux 侧验证)。 + +该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 + +四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 + +## 实测结果 + +2026-07-27 实测,热缓存,pull request 触发,标准 2 核 `ubuntu-latest`:端到端 2 分 46 秒——准备与缓存恢复约 17 秒,并发安装+供给 33 秒,并发门禁 110 秒——对照 Linux CI 作业的 1.5–2.5 分钟与被替换的 `windows-2025` 作业的 7–9 分钟。冷缓存约多付一分钟。实验期间定义过 8 核基准腿,但它从未离开受限 `dsh-ubuntu-*` 池的队列;标准 runner 的数字已达标,故不使用更大的机器。 + +## 考虑过的替代方案 + +**保留托管 `windows-2025` 的 pull request 作业(现状)。** 其信号没有问题,问题只在延迟:为两条构建命令花 7–9 分钟,是必需矩阵中最慢的作业。它作为 master 串行参照存续——在那里完整性比延迟更重要。 + +**在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可晋升。 + +**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道用掉这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 + +**Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 破坏类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。 + +**Windows 容器。** 不可行:Windows 容器要求 Windows 宿主内核;托管 Linux runner 无法运行。 + +**砍掉 Windows 通道。** 已否决——win32 是一等产品目标:基于 koffi 的 DACL 与持久命名空间模块、基于 ConPTY 的 PTY 会话、以及 Windows 路径策略都随 `packages/` 交付。 + +## 结果 + +每个 pull request 的 Windows 裁决现在以 Linux 作业的时间在免费标准容量上到达,pull request 关键路径上不再有任何 Windows VM 分配;`all checks passed` 消费的仍是原来的 `windows` 作业 id。 + +这笔交易的代价:Wine 在大小写敏感的 ext4 之上重实现 Win32——NTFS 大小写不敏感、真实 DACL、ConPTY 与崩溃持久性语义在此都未被证明,且观察性可移植性清单(duplication、publint、node-next 类型、win32 上的构建包不变量)完全不再于 pull request 上运行。master 的 `serial-windows` 参照拥有这一切:Wine 绿灯的 pull request 仍可能在原生内核的 master 运行上失败,该失败模式被接受为合并后处理。该通道还把 Wine 特有的分歧继承为永久的作业结构——文件中转的 stdio、宿主侧的 `vue` 链接、hoisted 布局——因此未来依赖 isolated 布局语义或进程内符号链接创建的工具链变更会先在这里以 Wine 失败而非产品失败的形式浮现,分诊必须如此归类。若 Wine 红灯在无产品原因的情况下反复出现,记录在案的退路是把 `windows` 作业还原为 git 历史中保存的 Wine 之前的 `windows-2025` 定义。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml similarity index 59% rename from .agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml index 0219885a7c..8a52737dfc 100644 --- a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml @@ -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-consolidate-gate-scripts-on-existing-deps.md: 0bf32e01ea407e2718f8ec39ca962587a37df9cc -2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: ba9f157a61ffdc415acf9b2a61857026fc2c8bf1 +2026-07-26-consolidate-gate-scripts-on-existing-deps.md: 6370c8f92eff7296327e941e698ec4f733100bb2 +2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: 3587c92e0e9655d8b38d24d184c1c68c44b131d4 diff --git a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md new file mode 100644 index 0000000000..6370c8f92e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md @@ -0,0 +1,33 @@ +# Agent Note: Consolidate gate scripts on already-present deps and builtins + +Status: implemented + +English | [中文](2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md) + +## Problem + +The `scripts/` gates mostly used the right tools (`node:fs` `globSync` in 15+ gates, mdast/micromark in the markdown gates), but a handful of stragglers hand-rolled what a sibling gate already did with an existing dependency or builtin: + +- **Duplicated fence scanners.** `scripts/md-fences.ts` (~55 lines, consumed by `doc-typecheck.ts`) and `extractEquivBlocks` in `scripts/verify-type-equiv.ts` (~39 lines) were two copies of the same regex line-scanner for fenced code blocks, while `scripts/verify-mermaid.ts` already extracted fences by visiting mdast `code` nodes — and `markdownProseLines` in `scripts/markdown.ts` itself parsed to mdast but then hand-tracked fence state with a second regex. The regex scanners only recognized backtick fences at column 0, so they silently disagreed with the mdast-based gates on tilde and indented fences. +- **Hand-rolled argv parsing.** `parseOptions` in `scripts/publint-all.ts` and its near-identical copy in `scripts/verify-built-package-invariants.mjs` (~26 lines) stepped argv indexes manually, while sibling scripts (`verify-runtime-closure.ts`, `build-exe-for-python-sdk.ts`, `packages/sdk/scripts/src/args.ts`) already used the `node:util` `parseArgs` builtin. +- **Hand-rolled directory walks.** Five sites re-derived nested `readdirSync` walks that `globSync` covers: `verify-runtime-closure.ts` (packages + vendor manifests), `dev-web.ts` `discoverPluginDirs`, `verify-package-paths.ts` `realPackageNames`, `verify-client-domain-graph.ts` `listSources`, and `publint-all.ts` `addPath` (~55–65 lines total). `scripts/package-invariants.ts` shows the one-line `globSync` template. + +No new dependency was needed anywhere; every replacement is an existing devDep or a Node builtin. + +## Decision + +- A shared mdast fence helper, `markdownFences` in `scripts/markdown.ts`, visits `code` nodes for the language, full info string, body, and 1-based opening-fence line; `doc-typecheck.ts` and `verify-type-equiv.ts` extract fences through it. `md-fences.ts` and the duplicated `extractEquivBlocks` scanner are deleted, and `markdownProseLines` derives fenced lines from the parsed `code` nodes' positions instead of a second regex. +- Both CLIs parse argv via `parseArgs`; unknown options and missing values still fail loud, with `parseArgs`'s own error text instead of the bespoke usage strings. +- The five straggler walks use `globSync`. The walks in `check-workspace-constraints.ts` and `clean.ts` stay: they need dirent-level detail to diagnose malformed trees, which glob-by-pattern cannot report. + +## Alternatives considered + +- **A new glob/walking dependency (`tinyglobby`, `fdir`).** Rejected: the builtin already won repo-wide; these were stragglers, not a gap. +- **`p-map` for `publint-all.ts`'s ~19-line ordered worker pool.** Deliberately left out: one new devDep for one small deletion is at the edge of the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md) bar, and the pool's requirements (bounded workers, deterministic order, env override) are documented in the [parallel-gates note](../process/2026-07-06-parallel-pre-push-gates.md). Fold it in only if `p-map` earns a second consumer. +- **Leaving the fence scanners.** Rejected: two drifting copies of a parser beside a third correct implementation is exactly the duplication the shared `markdown.ts` helper exists to prevent, and the column-0-backtick-only limitation was a latent inconsistency between sibling gates. + +## Consequences + +- One fence parser: every markdown gate now classifies fences through mdast, so tilde, indented, and 4-backtick container fences behave identically everywhere. The docs tree contained no fence shape the regex scanners mishandled, so gate results are unchanged on the tree that landed the swap: `pnpm run doc-sync` and each rewritten gate ran before and after with byte-identical output (`doc-typecheck` block/opt-out counts, `verify-type-equiv` match counts, `publint`, `verify-built-package-invariants`, `verify-runtime-closure`, `verify-package-paths`, `verify-client-domain-graph`, and both package-README prose gates). +- `verify-type-equiv` still rejects an unterminated type-equivalence fence: mdast silently closes an unterminated block at end-of-file (its comparisons could then pass), so the shared helper reports whether a closing delimiter exists and the gate errors on an unclosed block, preserving the removed scanner's rejection. The `doc-typecheck` scanner never had that error path. +- `parseArgs` keeps the last value of a duplicated option instead of erroring — a dev-tool edge case the tests don't pin, accepted in exchange for deleting the two bespoke parsers. (Strict mode still rejects a `--`-prefixed token where a value is expected, matching the replaced parsers.) diff --git a/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md new file mode 100644 index 0000000000..3587c92e0e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 把门禁脚本统一到已有依赖与内置模块上 + +Status: implemented + +[English](2026-07-26-consolidate-gate-scripts-on-existing-deps.md) | 中文 + +## 问题 + +`scripts/` 下的门禁大多本已在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本曾手写同类门禁早已用既有依赖或内置模块完成的事情: + +- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)曾是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 早已通过访问 mdast `code` 节点来提取代码围栏;`scripts/markdown.ts` 自己的 `markdownProseLines` 也曾先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。 +- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)曾手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)早已在使用 `node:util` 的内置 `parseArgs`。 +- **手写的目录遍历。**五处代码曾各自重写 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。 + +所有替换都不需要引入新依赖;每一处替换用的都是既有的 devDependency 或 Node 内置模块。 + +## 决策 + +- `scripts/markdown.ts` 中的共享 mdast 围栏辅助函数 `markdownFences` 访问 `code` 节点,读取语言、完整 info string、块体以及以 1 起始的开围栏行号;`doc-typecheck.ts` 和 `verify-type-equiv.ts` 通过它提取代码围栏。`md-fences.ts` 和重复的 `extractEquivBlocks` 扫描器已删除,`markdownProseLines` 也改为从解析出的 `code` 节点位置推导围栏内的行,而不再用第二个正则。 +- 两个 CLI 都改用 `parseArgs` 解析 argv;未知选项和缺失取值仍然大声失败,只是错误文案换成了 `parseArgs` 自带的文本,而非原先手写的用法字符串。 +- 那五处掉队的目录遍历改用 `globSync`。`check-workspace-constraints.ts` 和 `clean.ts` 中的遍历保留:它们需要 dirent 级别的细节来诊断结构异常的目录树,按模式匹配的 glob 报告不了这些信息。 + +## 曾考虑的替代方案 + +- **新的 glob/目录遍历依赖(`tinyglobby`、`fdir`)。**不予采纳:内置模块已在全仓库范围内胜出;这几处只是掉队者,不是能力缺口。 +- **用 `p-map` 替换 `publint-all.ts` 中约 19 行的有序 worker 池。**刻意未纳入:为一次小删除引入一个新 devDependency,正处在[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)门槛的边缘,而且该池的需求(worker 数量有界、确定性顺序、环境变量覆盖)已记录在[并行 pre-push 门禁决策记录](../process/2026-07-06-parallel-pre-push-gates.md)中。仅当 `p-map` 赢得第二个消费方时再顺带纳入。 +- **保留这两个围栏扫描器。**不予采纳:在第三个正确实现旁边放着两份逐渐漂移的解析器拷贝,正是共享的 `markdown.ts` 辅助函数要防止的那种重复;「只认第 0 列反引号」的限制也是同类门禁之间的潜在不一致。 + +## 后果 + +- 只剩一个围栏解析器:所有 markdown 门禁现在都经由 mdast 归类代码围栏,因此波浪线围栏、缩进围栏和四反引号容器围栏在各处的行为完全一致。文档树中不存在正则扫描器处理有误的围栏形态,所以在落地这次替换的代码树上门禁结果不变:`pnpm run doc-sync` 及每个被改写的门禁在改动前后各跑一遍,输出逐字节相同(`doc-typecheck` 的块数/opt-out 计数、`verify-type-equiv` 的匹配计数、`publint`、`verify-built-package-invariants`、`verify-runtime-closure`、`verify-package-paths`、`verify-client-domain-graph`,以及两个包 README 散文门禁)。 +- `verify-type-equiv` 仍然拒绝未闭合的类型等价围栏:mdast 会在文件末尾静默闭合未闭合的代码块(其比较随后可能通过),因此共享辅助函数会报告闭合定界符是否存在,门禁在块未闭合时报错,保留了被删扫描器的这条拒绝路径。`doc-typecheck` 的扫描器本来就没有这条错误路径。 +- `parseArgs` 对重复出现的选项保留最后一个值而不报错——一个测试未固定的开发工具边缘用例,作为删除两份手写解析器的交换被接受。(严格模式下,需要取值处遇到以 `--` 开头的 token 仍会拒绝,与被替换的解析器行为一致。) diff --git a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml similarity index 60% rename from .agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml index c486815180..860af008f4 100644 --- a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md new file mode 100644 index 0000000000..e7835bc738 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md @@ -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. diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md new file mode 100644 index 0000000000..933b993d47 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md @@ -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 在同一次变更中更新,而不是让声明陈旧下去。 diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml new file mode 100644 index 0000000000..41f59f644f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md +2026-07-26-turndown-for-tool-web-html-markdown.md: 0e387021e3d3be3011cc0d64d37864b30aec4fdf +2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 44e7d08c1db40a1203e8cda955b521774335e774 diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md new file mode 100644 index 0000000000..0e387021e3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md @@ -0,0 +1,37 @@ +# Agent Note: Replace tool-web's regex HTML-to-markdown converter with turndown + +Status: implemented + +English | [中文](2026-07-26-turndown-for-tool-web-html-markdown.zh.md) + +## Problem + +`dsh-tool-web`'s `src/html.ts` (~86 lines, ~40 lines of dedicated tests; deleted by this change) converted fetched HTML to markdown with regexes: strip script/style/noscript/comments, convert ``/``/`
  • `, decode numeric entities plus a 12-entry named-entity table, collapse whitespace. The module's own JSDoc said "A richer converter can replace it without changing the seam or tool schema", and the README's Known Limitations documented it as "a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost." The [web capability seam note](../architecture/2026-06-24-web-capability-seam.md) assigns HTML→markdown to this package as presentation, so the swap point was exactly here. The converter's output is model-visible on every fetched HTML page; no keyless snapshot exercised `web_fetch`, so no expected outputs pinned it. + +## Decision + +`packages/web/tool-web/src/fetch.ts` owns a module-level [`turndown`](https://github.com/mixmark-io/turndown) instance (`headingStyle: 'atx'`, `codeBlockStyle: 'fenced'`, `bulletListMarker: '-'` — fixed model-facing presentation, not deployment tunables) with `@joplin/turndown-plugin-gfm`'s composite `gfm` plugin for tables/strikethrough and `remove(['script', 'style', 'noscript'])` replacing the old wholesale drops. `formatFetchOutput` limits both the source prefix converted synchronously and the complete rendered output with `fetchMaxOutputChars` (default 200,000), so a custom provider cannot make conversion work unbounded before the output cap applies. The HTML arm then guards conversion twice: a conservative linear lexical pass treats comment contents conservatively, skips raw-text bodies, honors quoted tag text, and passes a body through raw when its stack crosses 512 levels; a try/catch also falls back to raw HTML when turndown rejects markup the guard cannot model. The GFM cell rule is overridden to ignore `colspan`, which Markdown cannot represent, rather than letting an untrusted numeric attribute synthesize arbitrary empty cells. `html.ts` and its conversion tests are deleted; the source/output bounds, fallback, and status-header/truncation-footer formatting are tested in `tests/tool-web.spec.ts`, and the README's Known Limitations trades the regex-converter caveat for the bounded degradation cases. The gfm plugin ships no types; `src/turndown-plugin-gfm.d.ts` declares the one imported export over `@types/turndown` (a devDependency). + +The dependency-weight question the proposal flagged resolves in favor of the swap: `@deepseek-ai/dsh-tool-web` is in the single-file-executable closure ([single-exe note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)), and the exe's asset globs would pack ~7.9 MB of the three packages as published — but ~6 MB of that is `@mixmark-io/domino`'s test corpus (`test/**`), with runtime `lib/` at ~550 KB against a ~174 MB artifact, under 0.5% either way. + +## Snapshot coverage + +The previously-missing keyless `web_fetch` snapshot ships with the change as the acp-agent scenario `web-fetch`: `examples/acp-agent/web.cordis.yml` composes the web seam, the real `dsh-web-fetch-local` provider, `tool-web` with `search: false`, and `web-fetch-fixture-server.mjs` — a loopback HTTP fixture on a fixed port (the fetched URL is part of the recorded transcript) serving deterministic HTML with named entities, a GFM table, and nested formatting. Recording and keyless replay both drive the real HTTP fetch and conversion; the pinned tool result is the turndown output, and the scenario pins the `web` header class (the `web_fetch` schema and guidance). + +## Alternatives considered + +- **`@mozilla/readability` + a DOM.** Solves a different problem (content extraction, not conversion) and drags a heavier DOM dependency; the seam only asks for markdown rendering of whatever the fetch returned. +- **Keep the regex converter.** It was an explicit v1 placeholder per its own JSDoc; keeping it meant model-visible quality (tables, images, nested formatting) stayed lost for the cost of maintaining bespoke entity tables. +- **The minimal `entities`-only variant.** The proposal's fallback position: replace only the entity-decoding third of `html.ts` with the zero-dependency `entities` package, deleting less but avoiding the dependency-weight question. Not taken because the closure math above made the weight immaterial while the full swap deletes the whole hand-rolled converter and its documented quality gaps. +- **`turndown-plugin-gfm` (the original) instead of `@joplin/turndown-plugin-gfm`.** The original is unmaintained (last publish 2018); the Joplin fork is current against turndown 7 and actively released. + +## Consequences + +- **Bought**: standards-based model-visible markdown — ordinary tables, images, strikethrough, nested emphasis, fenced code blocks, and the complete named-entity set — plus the deletion of the bespoke converter and its entity tables. +- **Paid**: two runtime dependencies (`turndown` → `@mixmark-io/domino`) enter tool-web and therefore the exe closure (~550 KB of runtime code as measured above); overlong input is converted only through a bounded prefix, pathological nesting falls back to raw HTML, and spanning table cells are flattened because GFM has no corresponding syntax. +- Model-visible output changed on every fetched HTML page; nothing pinned the old output, and the new snapshot pins the new one. + +## Testing + +- `packages/web/tool-web/tests/tool-web.spec.ts` covers the turndown conversion surface (entities, links, tables, nesting, script/style/noscript removal), ignored table spans, source-prefix and complete-output bounds, fast raw-HTML passthrough for deep or deceptively closed nesting, linear handling of malformed tags, the residual converter-throw fallback, and exact and tiny output budgets; per-file coverage on the package src is 100%. +- The `web-fetch` acp-agent snapshot pins the assembled behavior keylessly end to end (real Loader composition, real HTTP fetch, real conversion). diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md new file mode 100644 index 0000000000..44e7d08c1d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 用 turndown 替换 tool-web 的正则 HTML 转 markdown 转换器 + +Status: implemented + +[English](2026-07-26-turndown-for-tool-web-html-markdown.md) | 中文 + +## 问题 + +`dsh-tool-web` 的 `src/html.ts`(约 86 行,另有约 40 行专属测试;已由本变更删除)曾用正则表达式把抓取到的 HTML 转成 markdown:剥离 script、style、noscript 标签与注释,转换 ``/``/`
  • `,解码数字实体外加一张 12 项的命名实体表,并折叠空白。该模块自身的 JSDoc 写明「A richer converter can replace it without changing the seam or tool schema」,README 的 Known Limitations 章节也把它记载为「a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost」。[web 能力 seam 决策记录](../architecture/2026-06-24-web-capability-seam.md)把 HTML 转 markdown 作为呈现职责划归本包(package),因此替换点恰好就在这里。每个抓取到的 HTML 页面上,该转换器的输出都对模型可见;此前没有任何无密钥快照执行到 `web_fetch`,因此没有预期输出固定它的行为。 + +## 决策 + +`packages/web/tool-web/src/fetch.ts` 持有一个模块级 [`turndown`](https://github.com/mixmark-io/turndown) 实例(`headingStyle: 'atx'`、`codeBlockStyle: 'fenced'`、`bulletListMarker: '-'`——固定的面向模型呈现方式,不是部署可调项),配合 `@joplin/turndown-plugin-gfm` 的组合 `gfm` 插件提供表格/删除线支持,并用 `remove(['script', 'style', 'noscript'])` 替代旧实现的整体剥离。`formatFetchOutput` 通过 `fetchMaxOutputChars`(默认 200,000)同时限制同步转换的源前缀和完整渲染输出,因此自定义提供方无法在输出上限生效前造成无界的转换工作。随后,HTML 分支对转换做双重防护:保守的线性词法扫描会保守处理注释内容,跳过原始文本元素的内容,正确处理标签内的引号文本,并在栈深超过 512 层时将主体作为原始 HTML 直接透传;当 turndown 拒绝守卫无法建模的标记时,try/catch 同样回退为原始 HTML。GFM 单元格规则被覆写为忽略 `colspan`;Markdown 无法表示它,这也避免了不受信任的数值属性凭空合成任意数量的空单元格。`html.ts` 及其转换测试已删除;源/输出上限、回退以及状态头/截断页脚格式化均在 `tests/tool-web.spec.ts` 中有测试覆盖,README 的 Known Limitations 用有界降级情形替换了正则转换器警示。gfm 插件不带类型声明;`src/turndown-plugin-gfm.d.ts` 基于 `@types/turndown`(devDependency)声明了唯一被导入的导出。 + +提案标记的依赖体积问题的裁决结果支持替换:`@deepseek-ai/dsh-tool-web` 在单文件可执行文件闭包内([single-exe 决策记录](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)),可执行文件的资产 glob 会把这三个包按发布原样打入约 7.9 MB——但其中约 6 MB 是 `@mixmark-io/domino` 的测试语料(`test/**`),运行时 `lib/` 仅约 550 KB,相对约 174 MB 的产物,两种口径都不到 0.5%。 + +## 快照覆盖 + +此前缺失的无密钥 `web_fetch` 快照随本变更以 acp-agent 场景 `web-fetch` 落地:`examples/acp-agent/web.cordis.yml` 组合了 web seam、真实的 `dsh-web-fetch-local` 提供方、`search: false` 的 `tool-web`,以及 `web-fetch-fixture-server.mjs`——一个固定端口(抓取的 URL 是录制 transcript(文本记录)的一部分)上的回环 HTTP fixture,提供包含命名实体、GFM 表格与嵌套格式的确定性 HTML。录制与无密钥回放都驱动真实的 HTTP 抓取与转换;固定住的工具结果就是 turndown 的输出,该场景同时固定 `web` header 类(`web_fetch` 的 schema 与指引)。 + +## 曾考虑的替代方案 + +- **`@mozilla/readability` 加一个 DOM。** 它解决的是另一个问题(内容提取,而非格式转换),还会拖入更重的 DOM 依赖;这个 seam 只要求把抓取返回的内容渲染成 markdown。 +- **保留正则转换器。** 按其自身 JSDoc 的说法,它本来就是明确的 v1 占位实现;保留它意味着模型可见的质量(表格、图片、嵌套格式)继续缺失,代价还是维护一套自制实体表。 +- **仅引入 `entities` 的最小变体。** 提案中的退守方案:只用零依赖的 `entities` 包替换 `html.ts` 中的实体解码部分,删得更少但完全避开依赖体积问题。未采纳:上述闭包测算表明体积无关紧要,而完整替换能删掉整个手写转换器及其记录在案的质量缺口。 +- **用原版 `turndown-plugin-gfm` 而非 `@joplin/turndown-plugin-gfm`。** 原版已无人维护(最后发布于 2018 年);Joplin 分叉与 turndown 7 保持同步并持续发布。 + +## 后果 + +- **收益**:基于标准的模型可见 markdown——普通表格、图片、删除线、嵌套强调、围栏代码块以及完整的命名实体集——并删除了自制转换器及其实体表。 +- **代价**:两个运行时依赖(`turndown` → `@mixmark-io/domino`)进入 tool-web 进而进入可执行文件闭包(如上实测约 550 KB 运行时代码);超长输入只转换有界前缀,病态嵌套回退为原始 HTML,跨列表格单元格会被展平,因为 GFM 没有对应语法。 +- 每个抓取到的 HTML 页面上模型可见的输出都已变化;旧输出本无任何固定,新快照固定了新输出。 + +## 测试 + +- `packages/web/tool-web/tests/tool-web.spec.ts` 覆盖 turndown 转换面(实体、链接、表格、嵌套、script/style/noscript 移除)、被忽略的表格跨列、源前缀与完整输出上限、深层或带欺骗性闭合嵌套的快速原始 HTML 透传、畸形标签的线性处理、残余的转换器抛错回退,以及恰好达到上限和极小的输出预算;该包 src 的逐文件覆盖率为 100%。 +- acp-agent 的 `web-fetch` 快照无密钥地端到端固定组装后的行为(真实 Loader 组合、真实 HTTP 抓取、真实转换)。 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index 133198a4d2..d491394db7 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 8e86588f69..18c79bc2d0 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -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. diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index b70a46830f..d1d4a6ca85 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -33,7 +33,7 @@ TUI 覆盖分为四个互补层次: ### 语义终端投影 -包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀。 +包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定。流式输出检查点会冻结 loader 的 interval,同时保留跨过一次动画 tick 的真实墙钟等待,从而固定语义状态,而非调度器碰巧渲染出的某个加载动画字形。 每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index ff72d8e9ee..43ca03dc7a 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index c4e34b3f44..d9e0a9660e 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -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). diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 466e1c0fc1..e8c7d1c459 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -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)的崩溃修复改写)。 diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml index 5ab7a8229d..6e5f8b8391 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml @@ -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-domain-kv-storage-and-workspace.md: cd666a47a3cba4dea8846cd0f1373224e6fc456f -2026-07-24-domain-kv-storage-and-workspace.zh.md: 81adf1eb6bc32aa3ca8b9ef4c352fb94f95ace91 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md +2026-07-24-domain-kv-storage-and-workspace.md: 230877628428dc88dbddeecfe5f4353cf15e151d +2026-07-24-domain-kv-storage-and-workspace.zh.md: 050f72cd3327f83e2c3f3cefcab63c01e8f112ee diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md index cd666a47a3..2308776284 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md @@ -11,7 +11,9 @@ The host's only persistence surface is the session event log (`packages/session- - **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Until now workspace was only a sidebar visual grouping derived from cwd, with no entity (that conclusion has been overturned). - **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates. -Separately, workspace deletion will eventually need to delete its owned sessions, and `SessionPersistence` has no delete primitive nor does the host expose a `session.delete` endpoint — that gap's design is settled in this note, but its implementation is marked future work: this phase touches no session-side code. +Separately, Session deletion needs a `SessionPersistence` delete primitive and a `session.delete` endpoint. That gap's design is settled in this note, but its implementation remains future work. + +The later [Workspace registration deletion decision](../../implemented/feature/2026-07-27-workspace-registration-deletion.md) supersedes only that coupling: deleting a Workspace registration preserves its Sessions and their logs, while Session deletion remains separate future work. The cascade design below is therefore not the Workspace GUI delete semantic. ## Proposal @@ -234,14 +236,14 @@ export class WorkspaceRegistry extends Service { get(id: WorkspaceId): Workspace | undefined list(): Workspace[] resolveByPath(path: string): Promise // 同 realpath 口径,故 async - // delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口 + delete(id: WorkspaceId): Promise // 只删注册记录;目录与 session 日志保留 } ``` - **Path canon**: the stored value = `fs.realpath(input)` (trailing slashes, `..`, and symlinks all resolved); uniqueness = string equality after normalization (a symlink resolving to the same directory counts as a collision). A missing directory makes create reject outright (realpath fails — a workspace must point at an existing directory; "Create new = make the directory" is upper-layer interaction: mkdir first, then create). The session cwd in attach checks follows the same canon. Single-valued cwd + unique path ⇒ one session structurally belongs to at most one workspace; double bookkeeping is impossible on the write side. - **Title**: a display name, defaults to `basename(path)`, mutable, duplicates allowed. Ownership is never derived from cwd as a fallback — cwd cannot express ordering, and ownership is a workspace-side fact; sessions started headless belong to no workspace. - Consumers see only the `Workspace` interface; `WorkspaceEntity` stays inside the package (a single implementation does not pre-split a seam). Entities are unique per id (registry cache); the record snapshot is swapped in place after each write, and the outside sees getters only. Every write funnels through the entity's internal `mutate(fn)` → `table.update`, with `updatedAt` refreshed inside mutate. Domain objects never cross RPC; next phase the wire layer projects records into zod wire schemas. -- **Workspace deletion is future work as a whole** (settled 2026-07-24): the registry ships no delete method this phase — the half-measure "delete the record, keep the sessions" is not exposed; deletion and the session cascade (`recursive` parameter, running checks, bottom-up order, crash-rerun convergence) land as one complete semantic together with the session delete primitive; the order then is delete sessions one by one → prune the ledger → delete the workspace record. +- **Session deletion remains future work.** The later [Workspace registration deletion decision](../../implemented/feature/2026-07-27-workspace-registration-deletion.md) ships `ctx.workspace.delete(id)` as a metadata-only operation that preserves Sessions and logs. Recursive Session deletion, running checks, and crash-rerun convergence belong to a separate `session.delete` capability. Consistency doctrine (the ledger = the only ownership authority; the implementation and test baseline): @@ -285,7 +287,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha | Not doing | Trigger | Rework point | Groundwork | | --- | --- | --- | --- | -| The full deletion suite (`SessionPersistence.delete`, the deleted event, `registry.delete` cascade, recursive delete, running checks) | future work starts (before the GUI needs delete interactions) | implement per the future-work section above: the session primitive + `registry.delete(id, { recursive? })` land as one | orchestration rules and rejection table settled in this note; no deletion entry exists this phase, so no half-semantics to stay compatible with | +| Session deletion (`SessionPersistence.delete`, the deleted event, recursive delete, running checks) | a destructive Session-delete product flow starts | implement the session primitive plus `session.delete`; keep it independent from Workspace registration deletion | orchestration rules and rejection table above remain groundwork; Workspace deletion preserves Sessions and logs | | The `log` facet and the session-backend migration | any phase after this one | sink the medium operations (the reuse audit table is the work list) | the facet structure is in place; both backends' medium code is organized in sinkable shape already | | Multi-process write protection | two host processes writing one medium | JSON backend file locks; SQLite WAL is natively multi-process | all writes already funnel through the domain's single point; locking touches backends only | | Cross-process change observation | GUI reconnect awareness | the revision pattern (copy session-persistence) | `domain/changed` already exists in-process | @@ -296,7 +298,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha | Cross-table atomic transactions | one business operation touching two tables of one domain atomically | `domain.transact(fn)`; JSON whole-unit rewrite is naturally atomic, SQLite wraps a transaction | — | | Secondary indexes / conditional queries | in-memory filtering stops scaling (tens of thousands of records) | SQLite JSON1 over the value column, a read-only query facet on the seam | the JSON backend does not follow | | Moving a session across workspaces | a product need appears | relax the attach check into a "detach first, then attach" orchestration | — | -| RPC/GUI/boot | next phase | `workspace.*` + `session.delete` endpoints, wire schemas, boot mounting, sidebar on real data | this phase's model and semantics are the direct source of the wire projection | +| Session-delete RPC/GUI | a destructive Session-delete product flow starts | `session.delete` endpoint, wire schema, and explicit confirmation UI | Workspace RPC/GUI is shipped separately; no cascade coupling remains | ## Alternatives considered @@ -317,7 +319,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha ## Acceptance criteria - This phase's four test suites all green: the shared backend contract suite on both json/sqlite, registry/mount disposer semantics, the domain layer (including the six open steps and fail-loud routing), and full workspace semantics (create/attach checks/consistency doctrine). -- `ctx.workspace` completes the create → attach → list lifecycle under a test assembly (deletion is future work). +- `ctx.workspace` completes the create → attach → list → metadata-only delete lifecycle under a test assembly. - Zero diff in the session-persistence packages (the acceptance line for not touching the session side this phase). - No new snapshots this phase (no model-visible or assembly surface); added next phase with the RPC wiring. diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md index 81adf1eb6b..050f72cd33 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md @@ -11,7 +11,9 @@ host 侧唯一的持久化面是 session 事件日志(`packages/session-persis - **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。此前 workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体(该结论已被推翻)。 - **session 动态元信息**(可预见的第二个消费者)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。 -另外,workspace 删除最终需要删除其关联 session,而 `SessionPersistence` 没有删除原语,host 也没有 `session.delete` 端点——该空白的设计随本 Note 定案,但实施标记为 future work:本期不动 session 侧任何代码。 +另外,Session 删除需要 `SessionPersistence` 删除原语和 `session.delete` 端点。该空白的设计随本 Note 定案,但实现仍属未来工作。 + +后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)取代的仅是上述耦合关系:删除 Workspace 注册记录会保留相关 Session 及其日志,Session 删除仍是独立的未来工作。因此,下文的级联设计并不是 Workspace GUI 的删除语义。 ## Proposal @@ -234,14 +236,14 @@ export class WorkspaceRegistry extends Service { get(id: WorkspaceId): Workspace | undefined list(): Workspace[] resolveByPath(path: string): Promise // 同 realpath 口径,故 async - // delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口 + delete(id: WorkspaceId): Promise // 只删注册记录;目录与 session 日志保留 } ``` - **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 reject(realpath 失败——workspace 必须指向存在目录;"Create new = 建目录"是上层交互,先 mkdir 再 create)。attach 校验的 session cwd 同口径。cwd 单值 + path 唯一 ⇒ 一个 session 结构上最多归属一个 workspace,双重记账写侧不可能。 - **title**:显示名,默认 `basename(path)`,可改,允许重复。归属不用 cwd 派生兜底——cwd 表达不了排序,归属是 workspace 侧事实;headless 直开的 session 不属于任何 workspace。 - 消费者只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam);实体按 id 唯一(registry 缓存),记录快照写后原地换新,外部只见 getter;所有写收敛到实体内 `mutate(fn)` → `table.update`,`updatedAt` 在 mutate 内统一刷。领域对象不过 RPC,下期 wire 层把记录投影成 zod wire schema。 -- **workspace 删除整体为 future work**(2026-07-24 拍板):本期 registry 不提供 delete 方法——半截的"只删记录留 session"语义不对外暴露,删除与 session 级联(`recursive` 参数、运行中检查、自底向上、崩溃重跑收敛)作为一个完整语义随 session 删除原语一起落地;届时顺序为逐个删 session → 摘账 → 删记录。 +- **Session 删除仍属未来工作。** 后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)已将 `ctx.workspace.delete(id)` 作为仅删除元数据、保留 Session 与日志的操作交付。递归删除 Session、运行中检查和崩溃重跑收敛属于独立的 `session.delete` 能力。 一致性口径(账 = 归属唯一依据;实现与测试基准): @@ -285,7 +287,7 @@ export class WorkspaceRegistry extends Service { | 不做 | 触发条件 | 返工点 | 预埋 | | --- | --- | --- | --- | -| 删除全套(`SessionPersistence.delete`、deleted 事件、`registry.delete` 级联、递归删、运行中检查) | future work 启动(GUI 需要删除交互前) | 按上文 future work 节实施:session 原语 + `registry.delete(id, { recursive? })` 一体落地 | 编排规则/拒绝清单已定案在本 Note;本期无任何删除入口,无半截语义要兼容 | +| Session 删除(`SessionPersistence.delete`、deleted 事件、递归删除、运行中检查) | 破坏性的 Session 删除产品流启动 | 实现 Session 原语及 `session.delete`;与 Workspace 注册记录删除保持独立 | 上文编排规则和拒绝清单仍是基础;Workspace 删除会保留 Session 与日志 | | `log` facet 与 session 后端迁移 | 本期后任意期启动 | 介质操作下沉(复用审计表即施工清单) | facet 结构已留位;两后端介质代码本期即按可下沉形状组织 | | 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁;SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 | | 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence) | 进程内已有 `domain/changed` | @@ -296,7 +298,7 @@ export class WorkspaceRegistry extends Service { | 跨表原子事务 | 同域两表一次原子操作需求 | `domain.transact(fn)`;JSON 天然原子,SQLite 包事务 | — | | 二级索引/条件查询 | 内存过滤不动(万级记录) | SQLite JSON1 查 value 列,加只读 query 面 | JSON 后端不陪跑 | | session 跨 workspace 移动 | 产品需求出现 | attach 校验放宽为"先 detach 后 attach"编排 | — | -| RPC/GUI/boot | 下期 | `workspace.*` + `session.delete` 端点、wire schema、boot 挂载、sidebar 接真数据 | 本期模型与语义即 wire 投影的直接来源 | +| Session 删除 RPC/GUI | 破坏性的 Session 删除产品流启动 | `session.delete` 端点、wire schema 与明确的确认 UI | Workspace RPC/GUI 已独立交付,不再存在级联耦合 | ## Alternatives considered @@ -317,7 +319,7 @@ export class WorkspaceRegistry extends Service { ## Acceptance criteria - 测试矩阵本期四套件全绿:backend 契约共享套件在 json/sqlite 双端、registry/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud)、workspace 全语义(create/attach 校验/一致性口径)。 -- `ctx.workspace` 可在测试组装下完成 create → attach → list 生命周期(删除为 future work)。 +- `ctx.workspace` 可在测试组装下完成 create → attach → list → 仅删除元数据的 delete 生命周期。 - session-persistence 包零 diff(本期不动 session 侧的验收线)。 - 本期无新快照(无模型可见面与组装面);下期 RPC 接线时补。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md b/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md deleted file mode 100644 index 0bf32e01ea..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: Consolidate gate scripts on already-present deps and builtins - -Status: proposed - -English | [中文](2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md) - -## Problem - -The `scripts/` gates mostly use the right tools (`node:fs` `globSync` in 15+ gates, mdast/micromark in the markdown gates), but a handful of stragglers hand-roll what a sibling gate already does with an existing dependency or builtin: - -- **Duplicated fence scanners.** `scripts/md-fences.ts` (~55 lines, consumed by `doc-typecheck.ts`) and `extractEquivBlocks` in `scripts/verify-type-equiv.ts` (~39 lines) are two copies of the same regex line-scanner for fenced code blocks, while `scripts/verify-mermaid.ts` already extracts fences by visiting mdast `code` nodes via the shared `scripts/markdown.ts` helpers — and `markdownProseLines` in `markdown.ts` itself parses to mdast but then hand-tracks fence state with a second regex. The regex scanners only recognize backtick fences at column 0, so they silently disagree with the mdast-based gates on tilde and indented fences. -- **Hand-rolled argv parsing.** `parseOptions` in `scripts/publint-all.ts` and its near-identical copy in `scripts/verify-built-package-invariants.mjs` (~26 lines) step argv indexes manually, while sibling scripts (`verify-runtime-closure.ts`, `build-exe-for-python-sdk.ts`, `packages/sdk/scripts/src/args.ts`) already use the `node:util` `parseArgs` builtin. -- **Hand-rolled directory walks.** Five sites re-derive nested `readdirSync` walks that `globSync` covers: `verify-runtime-closure.ts` (packages + vendor manifests), `dev-web.ts` `discoverPluginDirs`, `verify-package-paths.ts` `realPackageNames`, `verify-client-domain-graph.ts` `listSources`, and `publint-all.ts` `addPath` (~55–65 lines total). `scripts/package-invariants.ts` shows the one-line `globSync` template. - -No new dependency is needed anywhere; every replacement is an existing devDep or a Node builtin. - -## Proposal - -- Extract a shared ~10–15-line mdast fence helper (visiting `code` nodes for `lang`, `meta`, `value`, `position.start.line`) into `scripts/markdown.ts`; rewrite `doc-typecheck.ts` and `verify-type-equiv.ts` onto it; delete `md-fences.ts` and the duplicated scanner; drop the redundant fence regex in `markdownProseLines`. -- Replace both `parseOptions` copies with `parseArgs`. -- Replace the five straggler walks with `globSync`. Keep the walks in `check-workspace-constraints.ts` and `clean.ts`: they need dirent-level detail to diagnose malformed trees, which glob-by-pattern cannot report. - -## Alternatives considered - -- **A new glob/walking dependency (`tinyglobby`, `fdir`).** Rejected: the builtin already won repo-wide; these are stragglers, not a gap. -- **`p-map` for `publint-all.ts`'s ~19-line ordered worker pool.** Deliberately left out: one new devDep for one small deletion is at the edge of the [dependency policy](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md) bar, and the pool's requirements (bounded workers, deterministic order, env override) are documented in the [parallel-gates note](../../implemented/process/2026-07-06-parallel-pre-push-gates.md). Fold it in only if `p-map` earns a second consumer. -- **Leaving the fence scanners.** Rejected: two drifting copies of a parser beside a third correct implementation is exactly the duplication the shared `markdown.ts` helper exists to prevent, and the column-0-backtick-only limitation is a latent inconsistency between sibling gates. - -## Acceptance criteria - -- `md-fences.ts` is gone; `doc-typecheck` and `verify-type-equiv` extract fences through `scripts/markdown.ts`; `pnpm run doc-sync` passes with unchanged results on the current tree (any delta traces to a fence shape the regex scanners mishandled). -- Both CLIs parse via `parseArgs`; unknown options still fail loud. -- The five walk sites use `globSync`; the gates they feed pass unchanged. - -## Risks - -- Behavioral deltas on pathological markdown: mdast honors tilde/indented fences the regex scanners ignored, so `doc-typecheck`'s opt-out ratio could shift if any stray fence shape exists in the docs tree; verify by running `doc-sync` before/after. -- `parseArgs` keeps the last value of a duplicated option instead of erroring — a dev-tool edge case the tests don't pin. (Strict mode still rejects a `--`-prefixed token where a value is expected, matching the current parsers.) diff --git a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md b/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md deleted file mode 100644 index ba9f157a61..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: 把门禁脚本统一到已有依赖与内置模块上 - -Status: proposed - -[English](2026-07-26-consolidate-gate-scripts-on-existing-deps.md) | 中文 - -## 问题 - -`scripts/` 下的门禁大多已经在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本仍在手写同类门禁早已用既有依赖或内置模块完成的事情: - -- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 已经通过共享的 `scripts/markdown.ts` 辅助函数访问 mdast `code` 节点来提取代码围栏;`markdown.ts` 自己的 `markdownProseLines` 也是先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。 -- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)已经在使用 `node:util` 的内置 `parseArgs`。 -- **手写的目录遍历。**五处代码各自重写了 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。 - -所有替换都不需要引入新依赖;每一处替换用的都是既有的 devDependency 或 Node 内置模块。 - -## 提案 - -- 在 `scripts/markdown.ts` 中提取一个约 10–15 行的共享 mdast 围栏辅助函数(访问 `code` 节点,读取 `lang`、`meta`、`value`、`position.start.line`);把 `doc-typecheck.ts` 和 `verify-type-equiv.ts` 改写到它上面;删除 `md-fences.ts` 和重复的扫描器;去掉 `markdownProseLines` 中冗余的围栏正则。 -- 用 `parseArgs` 替换两份 `parseOptions` 拷贝。 -- 用 `globSync` 替换那五处掉队的目录遍历。保留 `check-workspace-constraints.ts` 和 `clean.ts` 中的遍历:它们需要 dirent 级别的细节来诊断结构异常的目录树,按模式匹配的 glob 报告不了这些信息。 - -## 曾考虑的替代方案 - -- **新的 glob/目录遍历依赖(`tinyglobby`、`fdir`)。**不予采纳:内置模块已在全仓库范围内胜出;这几处只是掉队者,不是能力缺口。 -- **用 `p-map` 替换 `publint-all.ts` 中约 19 行的有序 worker 池。**刻意未纳入:为一次小删除引入一个新 devDependency,正处在[依赖策略](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md)门槛的边缘,而且该池的需求(worker 数量有界、确定性顺序、环境变量覆盖)已记录在[并行 pre-push 门禁决策记录](../../implemented/process/2026-07-06-parallel-pre-push-gates.md)中。仅当 `p-map` 赢得第二个消费方时再顺带纳入。 -- **保留这两个围栏扫描器。**不予采纳:在第三个正确实现旁边放着两份逐渐漂移的解析器拷贝,正是共享的 `markdown.ts` 辅助函数要防止的那种重复;「只认第 0 列反引号」的限制也是同类门禁之间的潜在不一致。 - -## 验收标准 - -- `md-fences.ts` 已删除;`doc-typecheck` 与 `verify-type-equiv` 通过 `scripts/markdown.ts` 提取代码围栏;`pnpm run doc-sync` 在当前代码树上通过且结果不变(如有差异,必须能追溯到正则扫描器处理有误的某种围栏形态)。 -- 两个 CLI 都改用 `parseArgs` 解析;未知选项仍然大声失败。 -- 五处遍历代码改用 `globSync`;它们供给的门禁保持原样通过。 - -## 风险 - -- 病态 markdown 上的行为差异:mdast 会承认正则扫描器忽略的波浪线围栏和缩进围栏,因此如果文档树中存在任何零散的此类围栏形态,`doc-typecheck` 的 opt-out 比例可能变化;应在改动前后分别运行 `doc-sync` 加以验证。 -- `parseArgs` 对重复出现的选项保留最后一个值而不报错——一个测试未固定的开发工具边缘用例。(严格模式下,需要取值处遇到以 `--` 开头的 token 仍会拒绝,与现有解析器行为一致。) diff --git a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md b/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md deleted file mode 100644 index 8a93b7f6c7..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md +++ /dev/null @@ -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. diff --git a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md b/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md deleted file mode 100644 index b16109d945..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md +++ /dev/null @@ -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,而不是让声明陈旧下去。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md b/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md deleted file mode 100644 index 7f25e51bf6..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: Replace tool-web's regex HTML-to-markdown converter with turndown - -Status: proposed - -English | [中文](2026-07-26-turndown-for-tool-web-html-markdown.zh.md) - -## Problem - -`packages/web/tool-web/src/html.ts` (~86 lines, ~40 lines of dedicated tests) converts fetched HTML to markdown with regexes: strip script/style/noscript/comments, convert ``/``/`
  • `, decode numeric entities plus a 12-entry named-entity table, collapse whitespace. The module's own JSDoc says "A richer converter can replace it without changing the seam or tool schema", and the README's Known Limitations documents it as "a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost." The [web capability seam note](../../implemented/architecture/2026-06-24-web-capability-seam.md) assigns HTML→markdown to this package as presentation, so the swap point is exactly here. The converter's output is model-visible on every fetched HTML page; no keyless snapshot currently exercises `web_fetch`, so no expected outputs pin it. - -## Proposal - -Replace `htmlToMarkdown` with `turndown` (`new TurndownService().turndown(html)`), optionally with `turndown-plugin-gfm` for tables. The consumer switch in `fetch.ts` and the status-header/truncation-footer formatting stay. Wrap the call in try/catch falling back to the raw text path: the regex version could never throw; turndown on pathological HTML could. Delete `html.ts` and its conversion tests; keep tests for the fallback and the surrounding formatting. Update the README's Known Limitations to drop the regex-converter caveat. - -If the "deliberately minimal fallback" stance is preferred instead, a minimal variant still deletes the worst part: replace the entity-decoding third of the file (~30 lines: `decodeEntities`, `NAMED_ENTITIES`, `safeFromCodePoint`) with the zero-dependency `entities` package (already in the lockfile transitively), erasing the documented "about a dozen entities" limitation at near-zero risk. - -## Alternatives considered - -- **`@mozilla/readability` + a DOM.** Solves a different problem (content extraction, not conversion) and drags a heavier DOM dependency; the seam only asks for markdown rendering of whatever the fetch returned. -- **Keep the regex converter.** It was an explicit v1 placeholder per its own JSDoc; keeping it means model-visible quality (tables, images, nested formatting) stays lost for the cost of maintaining bespoke entity tables. -- **The minimal `entities`-only variant.** Kept in the proposal as the fallback position; it deletes less but avoids the dependency-weight question entirely. - -## Acceptance criteria - -- `web_fetch` renders tables/nested formatting via turndown (or, minimal variant: decodes all named entities), with the README limitation updated. -- Unit tests cover the fallback path; `pnpm run test` passes for the package. -- A keyless snapshot exercising `web_fetch` markdown rendering is added per testing policy (the missing snapshot coverage is part of the change, and it pins the new output). - -## Risks - -- Model-visible output changes on every fetched HTML page — transcript drift is acceptable pre-release, and nothing currently pins the old output. -- Dependency weight: turndown's one dependency (`@mixmark-io/domino`) is a ~200 KB DOM that would enter the single-file-executable closure if tool-web ships in it ([single-exe note](../../implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)); the minimal `entities` variant avoids this if closure size is the deciding factor. diff --git a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md b/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md deleted file mode 100644 index 3a59b08e13..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: 用 turndown 替换 tool-web 的正则 HTML 转 markdown 转换器 - -Status: proposed - -[English](2026-07-26-turndown-for-tool-web-html-markdown.md) | 中文 - -## 问题 - -`packages/web/tool-web/src/html.ts`(约 86 行,另有约 40 行专属测试)用正则表达式把抓取到的 HTML 转成 markdown:剥离 script、style、noscript 标签与注释,转换 ``/``/`
  • `,解码数字实体外加一张 12 项的命名实体表,并折叠空白。该模块自身的 JSDoc 写明「A richer converter can replace it without changing the seam or tool schema」,README 的 Known Limitations 章节也把它记载为「a minimal regex converter, not an HTML parser — tables, images, and nested formatting are lost」。[web 能力 seam 决策记录](../../implemented/architecture/2026-06-24-web-capability-seam.md)把 HTML 转 markdown 作为呈现职责划归本包(package),因此替换点恰好就在这里。每个抓取到的 HTML 页面上,该转换器的输出都对模型可见;当前没有任何无密钥快照执行到 `web_fetch`,因此没有预期输出固定它的行为。 - -## 提案 - -用 `turndown` 替换 `htmlToMarkdown`(`new TurndownService().turndown(html)`),可选择配合 `turndown-plugin-gfm` 支持表格。`fetch.ts` 中的消费方分支与状态头、截断页脚的格式化保持不变。把调用包在 try/catch 中,失败时回退到原始文本路径:正则版本从不可能抛异常,而 turndown 处理病态 HTML 时可能抛出。删除 `html.ts` 及其转换测试;保留回退路径与外围格式化的测试。更新 README 的 Known Limitations 章节,移除正则转换器的警示说明。 - -如果更倾向于「刻意保持最小回退实现」的立场,最小变体仍能删掉最糟的部分:用零依赖的 `entities` 包(已通过传递依赖存在于 lockfile 中)替换文件中占三分之一的实体解码部分(约 30 行:`decodeEntities`、`NAMED_ENTITIES`、`safeFromCodePoint`),以近乎为零的风险抹掉文档记载的「about a dozen entities」限制。 - -## 曾考虑的替代方案 - -- **`@mozilla/readability` 加一个 DOM。** 它解决的是另一个问题(内容提取,而非格式转换),还会拖入更重的 DOM 依赖;这个 seam 只要求把抓取返回的内容渲染成 markdown。 -- **保留正则转换器。** 按其自身 JSDoc 的说法,它本来就是明确的 v1 占位实现;保留它意味着模型可见的质量(表格、图片、嵌套格式)继续缺失,代价还是维护一套自制实体表。 -- **仅引入 `entities` 的最小变体。** 已作为退守方案保留在提案中;它删得更少,但完全避开了依赖体积问题。 - -## 验收标准 - -- `web_fetch` 经由 turndown 渲染表格与嵌套格式(或在最小变体下:解码全部命名实体),README 中的限制说明同步更新。 -- 单元测试覆盖回退路径;该包的 `pnpm run test` 通过。 -- 按测试政策补充一个执行 `web_fetch` markdown 渲染的无密钥快照(缺失的快照覆盖是本变更的一部分,它同时固定新输出)。 - -## 风险 - -- 模型可见的输出在每个抓取到的 HTML 页面上都会变化:预发布阶段的 transcript(文本记录)漂移可以接受,且当前没有任何东西固定旧输出。 -- 依赖体积:turndown 的唯一依赖(`@mixmark-io/domino`)是一个约 200 KB 的 DOM 实现,若 tool-web 进入单文件可执行文件,它会一并进入闭包([single-exe 决策记录](../../implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md));若闭包体积是决定因素,最小的 `entities` 变体可以避开这一点。 diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml similarity index 58% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml index 56e0178e8d..2dc0338121 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml @@ -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-evaluate-landstrip-for-windows-sandbox-rung.md: 047449f4915c973e86cdb9f05f6dc51535133534 -2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 379d57e1e0006bf8f567d0b750ca0bb641ca6b49 +2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 236139f9198f178d44cdf0867cbad2377a127359 +2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 3932f73a2bf147ce5088b5c42e85982c70cdb945 diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md similarity index 93% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md index 047449f491..236139f919 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md @@ -1,6 +1,6 @@ # Agent Note: Evaluate landstrip before building a Windows sandbox launcher -Status: proposed +Status: rejected — landstrip is not battle-tested (a days-old single-maintainer project, ~48 GitHub stars at rejection); a security-invariant dependency must have proven adoption, so the win32 rung keeps the in-house-launcher plan English | [中文](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md) diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md similarity index 93% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md index 379d57e1e0..3932f73a2b 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md @@ -1,6 +1,6 @@ # Agent Note: 在构建 Windows 沙箱启动器之前先评估 landstrip -Status: proposed +Status: rejected — landstrip 未经实战检验(问世仅数天、单一维护者、驳回时 GitHub 星标约 48 个);安全不变式级的依赖必须有成熟的采用度,因此 win32 梯级维持自研启动器的原计划 [English](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index d54dd4f69c..082ac040e9 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml @@ -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: b1815967698f24cffc4abb07da364fa8a008cb6c -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 7f04ed15461f004e61c1de5729b2235b1a35b0ba +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 9cdb061bf21a7b9ce4747b9022c65d60e9644e0d +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 9648af149c0c517eba372baa12877240f9289aac diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index b181596769..9cdb061bf2 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -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:** @@ -67,7 +67,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`syncpack`/`manypkg` for `check-workspace-constraints.ts`**: they cover ~20 lines of range alignment; the load-bearing 200+ lines (computed `files` lists, cordis peer=dev pairing, hierarchy shape) are repo policy no generic engine expresses. - **`remark-validate-links` for `verify-md-links.ts`**: the gate rides the repo's shared mdast toolchain; adopting remark-cli adds a second markdown stack to delete one small file. - **`prebuildify`/`node-gyp-build` for the landlock launcher packaging**: inapplicable — those load `.node` addons via dlopen; the launcher ships a standalone exec'd static binary, and per-platform `optionalDependencies` *is* the ecosystem convention for binaries. -- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung is a different question — see the [landstrip evaluation proposal](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md).) +- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung was weighed separately and also [rejected](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — landstrip is not battle-tested.) - **`hatch-nodejs-version` for Python release versioning**: roughly LOC-neutral (a custom metadata hook replaces the regex), inverts the recorded decision that the dev sentinel never determines a release version, and puts a single-maintainer build plugin in the release supply chain. - **YAML consolidation (`js-yaml` vs `yaml`)**: the repo carries both parsers, with the `!!js` tag defined four times on js-yaml (vendored include, app-boot, apps/cli, `scripts/verify-cordis-config.ts`) and twice on `yaml` (sdk-telemetry's `ScalarTag`, sdk-helper's comment-preserving Document editing). The direction is forced — js-yaml cannot replace `yaml` (sdk-helper needs the Document API) — but migrating the js-yaml sites cannot retire the library either (the vendored include pins it) and would put two parsers in charge of one dialect that must agree exactly, against the [personal-config note](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)'s deliberate load-only-copy parity. Deletable: ~20–25 lines of duplicate tag definitions and two `@types/js-yaml` entries. The consolidation moment is a future include sync, not now. diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index 7f04ed1546..9648af149c 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -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):那里线路对面是真实的提供方。) **重试、定时器与异步:** @@ -67,7 +67,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `syncpack`/`manypkg` 替换 `check-workspace-constraints.ts`**:它们只覆盖约 20 行的版本范围对齐;承重的 200+ 行(计算生成的 `files` 列表、cordis peer=dev 配对、层级形状)是仓库政策,没有通用引擎能表达。 - **以 `remark-validate-links` 替换 `verify-md-links.ts`**:该门禁搭载仓库共享的 mdast 工具链;采用 remark-cli 等于为删掉一个小文件而增加第二套 markdown 技术栈。 - **以 `prebuildify`/`node-gyp-build` 承担 landlock 启动器打包**:不适用——那些工具通过 dlopen 加载 `.node` addon;这个启动器交付的是独立 exec 的静态二进制,而按平台划分的 `optionalDependencies` 恰恰*就是*二进制分发的生态惯例。 -- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级是另一个问题——见 [landstrip 评估提案](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)。) +- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级经单独权衡后同样被[驳回](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——landstrip 未经实战检验。) - **以 `hatch-nodejs-version` 承担 Python 发布版本号**:代码行数大致持平(一个自定义 metadata 钩子换掉那个正则),却反转了「dev 哨兵值绝不决定发布版本」这条记录在案的决策,还把一个单一维护者的构建插件放进发布供应链。 - **YAML 归一(`js-yaml` 与 `yaml`)**:仓库同时携带两个解析器,`!!js` 标签在 js-yaml 上定义了四次(vendor 收录的 include、app-boot、apps/cli、`scripts/verify-cordis-config.ts`),在 `yaml` 上定义了两次(sdk-telemetry 的 `ScalarTag`、sdk-helper 的保留注释式 Document 编辑)。方向是被迫的——js-yaml 无法取代 `yaml`(sdk-helper 需要 Document API)——但迁移 js-yaml 各调用点也退休不了这个库(vendor 收录的 include 锁定了它),还会让两个解析器共管一种必须完全一致的方言,违背[个人配置决策](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)刻意的「仅加载副本」对等性。可删除的:约 20–25 行重复标签定义和两条 `@types/js-yaml` 条目。归一的时机是未来某次 include 同步,不是现在。 diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index 76344cbc6c..44de289f7f 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.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 `. The PR body should give word deltas, explain any deliberately long exception, and list checks. diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 40b601ea58..352320612c 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -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 ` (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 ` 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 ` then `pnpm run verify-translation-pairing `. `--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 > /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 ` 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 diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 5f03c8617d..ff4fd4e6b2 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run Windows jobs under native `pwsh`. +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is not one of them: it runs Windows Node under Wine on hosted Linux, so its steps are bash — see the [Wine lane Agent Note](../.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f02d30a563..e3b519130e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,24 @@ jobs: # Three enterprise jobs isolate coverage, static analysis, and the # build-backed consumer tail. The static job publishes its exact build so # consumers do not repeat the longest part of their critical path. + # + # FAILOVER: each Linux enterprise job resolves its pool through the + # DSH_CI_FAILOVER repository variable. Unset (normal), the expressions + # pick the hosted enterprise pools below. Setting the variable to + # 'selfhosted' (repo Settings → Actions → Variables; writer-manageable + # repository state — not PR-editable, no merge required) retargets all + # three onto the in-house + # vm-backup pool and re-running the failed jobs is the entire switch — + # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The + # in-house pool's readiness is re-proven on every master push by the + # serial-linux-selfhosted standby lane below. node-24: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-latest-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / static env: DSH_GATE_CONCURRENCY: '8' @@ -44,8 +59,10 @@ jobs: persist-credentials: false # Pull requests consume the default-branch cache but do not put cache - # compression and upload on the paid latency-critical path. + # compression and upload on the paid latency-critical path. Skipped + # under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -81,17 +98,30 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-24-04-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-24-04-32core-test' }} name: node 24 / coverage env: - DSH_COVERAGE_MAX_WORKERS: '24' + # Failover shrinks the worker bound: the hosted 32-core runner is + # exclusive to one job, but the failover pool shares one 64-core VM + # across six always-on runner instances, and the timing-sensitive + # process suites have documented aggregate-contention failures. + # 8 × 6 instances = 48 workers worst case on 64 cores. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '24' }} DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 with: persist-credentials: false + # Skipped under failover: the self-hosted VM's persistent pnpm store + # serves warm installs directly, and this hosted-path restore would + # spend ~52 s pulling ~180 MB into a path pnpm never reads there. - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -122,7 +152,11 @@ jobs: node-24-consumers: needs: node-24 if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-latest-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / snapshots and artifacts env: DSH_ESLINT_CACHE: '1' @@ -130,7 +164,8 @@ jobs: DSH_GATE_CONCURRENCY: '8' DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_PUBLINT_CONCURRENCY: '8' - DSH_SNAPSHOT_MAX_CONCURRENCY: '32' + # Failover halves snapshot concurrency for the shared 64-core VM. + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} steps: - uses: actions/checkout@v6 with: @@ -144,7 +179,9 @@ jobs: - name: Restore built tree run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz" + # Skipped under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -281,41 +318,224 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # One standard Windows box shares setup across the required build/site checks - # and the observational portability inventory. Serial worker bounds keep this - # recovery path portable; Linux owns duplicate lint, coverage, and snapshots. + # The required pull-request Windows signal: the two blocking win32 surfaces + # (workspace build, production site) execute with real, checksum-verified + # Windows Node under Wine on standard hosted Linux. The master + # serial-windows job below keeps the complete native-kernel inventory — + # including the observational portability gates this lane does not run — + # on real windows-2025. Direct tool entrypoints stand in for pnpm's cmd + # shims, which a Linux-side install does not create; layout, fidelity + # limits, and measured timings live in + # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md windows: if: github.event_name == 'pull_request' - runs-on: windows-2025 - name: windows node 24 / complete + runs-on: ubuntu-latest + name: windows node 24 / wine blocking + timeout-minutes: 15 env: - DSH_COVERAGE_MAX_WORKERS: '1' - DSH_GATE_CONCURRENCY: '1' - DSH_PUBLINT_CONCURRENCY: '1' + WINEDEBUG: '-all' + WINEARCH: win64 + # Skip Wine Mono / Gecko installers: Node needs neither. + WINEDLLOVERRIDES: 'mscoree,mshtml=' steps: - uses: actions/checkout@v6 - - - name: Enable Developer Mode (symlink support) - shell: pwsh - run: >- - reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + with: + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - # Extracting the many-file pnpm store cache is slower than a clean install, - # and saving it adds more latency after gates. - - name: Enable corepack and install (immutable) - shell: pwsh + - uses: actions/cache/restore@v4 + with: + path: /home/runner/.local/share/pnpm/store/v11 + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + # Master's wine-apt-cache job seeds the default-branch scope every pull + # request can read; a save from this job only reaches reruns of the + # same merge ref. + - name: Compose Wine apt cache key + id: wine-cache-key + run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ~/wine-debs + key: ${{ steps.wine-cache-key.outputs.key }} + + - name: Install dependencies and provision Wine concurrently run: | corepack enable - pnpm install --frozen-lockfile - - name: Run blocking and observational Windows gates concurrently - shell: pwsh - run: pnpm run check:ci:windows-complete + # Windows-lane install-time overrides. supportedArchitectures + # additionally materializes the win32-x64 platform packages + # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) the + # Windows toolchain resolves at runtime; nodeLinker: hoisted lays + # node_modules out flat with real files because Windows Node under + # Wine does not realpath pnpm's isolated-layout symlinks. Neither + # override is recorded in the lockfile, so --frozen-lockfile stays + # valid. --ignore-scripts skips Linux lifecycle scripts no gate in + # this lane loads; the win32 binaries ship prebuilt. + cat >> pnpm-workspace.yaml <<'EOF' + + nodeLinker: hoisted + supportedArchitectures: + os: [current, win32] + cpu: [current, x64] + EOF + + pnpm install --frozen-lockfile --ignore-scripts & + install_pid=$! + + provision_wine() { + set -euo pipefail + # Wine from the apt cache when present; else download the full + # dependency closure once and keep it for the next run. The + # `wine` dispatcher package (not bare `wine64`) is what puts a + # binary on PATH. + if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then + sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends --download-only wine + mkdir -p "$HOME/wine-debs" + cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true + sudo apt-get install -y --no-install-recommends wine + fi + WINE_BIN='' + for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do + if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi + done + [ -n "$WINE_BIN" ] || { echo '::error::no wine binary found after install'; exit 1; } + echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV" + + # Windows Node for the repo's primary line, checksum-verified + # against the same dist directory. + version=$(curl -fsSL https://nodejs.org/dist/index.json \ + | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version') + echo "Windows Node: $version" + curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \ + "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip" + curl -fsSL "https://nodejs.org/dist/${version}/SHASUMS256.txt" \ + | awk -v a="node-${version}-win-x64.zip" '$2 == a { print $1 " '"$RUNNER_TEMP"'/node-win.zip" }' \ + | sha256sum --check - + unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win" + echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV" + + "$WINE_BIN" wineboot --init || true + wineserver -w || true + } + provision_wine & + wine_pid=$! + + install_status=0 + wait "$install_pid" || install_status=$? + wine_status=0 + wait "$wine_pid" || wine_status=$? + if (( install_status != 0 )); then exit "$install_status"; fi + exit "$wine_status" + + - name: Resolve entrypoints, link vue, smoke Windows Node + run: | + # Node under Wine cannot attach stdio to the Actions runner's pipes + # (Socket open EBADF at bootstrap), so every invocation runs through + # this wrapper: stdio to a regular file, replayed after exit. + cat > "$RUNNER_TEMP/wine-node.sh" <<'SH' + #!/usr/bin/env bash + set -u + log="$1"; shift + "$WINE_BIN" "$NODE_WIN" "$@" < /dev/null > "$log" 2>&1 + status=$? + tail -n 300 "$log" + exit "$status" + SH + chmod +x "$RUNNER_TEMP/wine-node.sh" + + resolve() { + local name="$1"; shift + for p in "$@"; do + if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi + done + echo "::error::$name not found at any of: $*"; return 1 + } + resolve TSC_JS node_modules/typescript/bin/tsc + resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs + resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js + + # VitePress links vue into the site's node_modules at build time; + # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows + # pre-existing Unix ones, so lay the link down host-side. + if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then + mkdir -p website/node_modules + ln -s ../../node_modules/vue website/node_modules/vue + fi + + "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" + + # The two blocking surfaces run concurrently, the same shape run-gates + # gives ci-windows-blocking on native Windows: `build` = tsc -b then + # tsdown, `production site` = the VitePress build. Both statuses are + # captured so one failure cannot hide the other's result. + - name: Run blocking Windows gates concurrently under Wine + run: | + build_gate() { + "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false || return $? + "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS" + } + site_gate() { + cd website + "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build . + } + start=$SECONDS + build_gate > "$RUNNER_TEMP/build-gate.out" 2>&1 & + build_pid=$! + site_gate > "$RUNNER_TEMP/site-gate.out" 2>&1 & + site_pid=$! + build_status=0 + wait "$build_pid" || build_status=$? + site_status=0 + wait "$site_pid" || site_status=$? + echo "== build gate (exit $build_status, $((SECONDS - start))s elapsed) ==" + tail -n 120 "$RUNNER_TEMP/build-gate.out" + echo "== production site gate (exit $site_status, $((SECONDS - start))s elapsed) ==" + tail -n 120 "$RUNNER_TEMP/site-gate.out" + if (( build_status != 0 )); then exit "$build_status"; fi + exit "$site_status" + + - name: Shut down wineserver + if: always() + run: wineserver -k 2>/dev/null || true + + # Master seeds the Wine apt-archive cache in the default-branch scope, + # which every pull request's windows job can restore; saves from + # pull-request runs are scoped to their own merge ref and help nobody + # else. Runs in seconds when the image version already has a cache. + wine-apt-cache: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + name: wine apt cache + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Compose Wine apt cache key + id: wine-cache-key + run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + id: wine-cache + with: + path: ~/wine-debs + key: ${{ steps.wine-cache-key.outputs.key }} + + - name: Download the Wine dependency closure + if: steps.wine-cache.outputs.cache-hit != 'true' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends --download-only wine + mkdir -p "$HOME/wine-debs" + cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" + du -sh "$HOME/wine-debs" # Master pushes run only the serial reference jobs below. # Each host executes the complete, unsharded primary Node aggregate with one @@ -372,6 +592,56 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + # Hot-standby drill for the in-house self-hosted pool: every master move + # re-runs the complete unsharded aggregate on the persistent 64-core VM, + # continuously proving that environment can take over a required lane if + # the hosted pools degrade (the switch is then setting the writer-manageable + # DSH_CI_FAILOVER variable — see the failover runbook, no merge required). + # Push-triggered, so this lane always executes the base branch's own + # workflow definition. (Under failover, pull_request jobs do reach these + # runners with the PR merge ref's workflow — the boundary there is + # repository membership: private, forking disabled, Dependabot excluded.) + # Non-blocking for + # pull requests; no cache steps because the VM's persistent pnpm store and + # tool caches make them redundant (and saving here would poison the hosted + # cache namespace with self-hosted paths). + serial-linux-selfhosted: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + name: serial / linux (self-hosted standby) + runs-on: [self-hosted, linux, x64, vm-backup] + steps: + # Full history + DSH_ARCHIVE_BASE_REF below: same frozen-archive + # comparison as serial-linux. Depth 2 would miss github.event.before + # on multi-commit or force pushes; full fetch is cheap here because + # checkout resolves against the VM's local mirror. + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Prepare bubblewrap (unrestrict userns) + run: bash scripts/prepare-ci-bubblewrap.sh + + - name: Run complete unsharded primary Node CI serially + env: + DSH_ARCHIVE_BASE_REF: ${{ github.event.before }} + DSH_COVERAGE_MAX_WORKERS: '1' + DSH_E2E_MAX_WORKERS: '1' + DSH_ESLINT_CACHE: '1' + DSH_GATE_CONCURRENCY: '1' + DSH_PUBLINT_CONCURRENCY: '1' + DSH_SNAPSHOT_MAX_CONCURRENCY: '1' + run: pnpm run check:ci + serial-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / macos @@ -693,8 +963,16 @@ 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 (master's #734 moved it to ubuntu-latest) — and under + # failover it follows the same selector as the worker jobs it + # aggregates, so a standard-hosted outage cannot strand the + # branch-protection verdict either (observed 2026-07-27). + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'ubuntu-latest' }} needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows] if: always() && github.event_name == 'pull_request' steps: diff --git a/.gitignore b/.gitignore index 71bdbda771..4651b94215 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,8 @@ pnpm-debug.log .pnpm-store/ .cache/ examples/*/*.jsonl -.sessions/ .storages/ +.sessions/ examples/*/.sessions/ coverage/ .doc-typecheck-*/ @@ -29,3 +29,4 @@ python/**/.pytest_cache/ apps/web/dist/ .artifacts/ .playwright-mcp/ +.orig \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 43b000b298..d6d0365dfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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- workspaces at packages/// 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 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 8f0899023d..efd75c1cf5 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -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' diff --git a/apps/cli/package.json b/apps/cli/package.json index 968c9a0ed5..e0a3a51c94 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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:^", diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index 6f4085da44..f53777fff8 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -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", ], } `) diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 8b25bad5ca..32c51a2a2a 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -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 () => { diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index e52e316862..4b54242495 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -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 () => { diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index a74833cef6..692210b352 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -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) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 2c2709a8f0..46f6af7b86 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -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 () => { diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 131f3fa5fd..10f374c920 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -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 () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6265515764..d57b934068 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -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 }] }] diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts new file mode 100644 index 0000000000..29c1d68f7a --- /dev/null +++ b/apps/web/tests/slash-flow.snapshot.ts @@ -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).__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 { + 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( + '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( + '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", + } + `) +}) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 5a4230cc05..3d16b72e35 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -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' function waitForReadyLine(child: ChildProcess): Promise { return new Promise((resolveReady, reject) => { @@ -393,6 +393,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') diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 1c93ff2d36..99f92014ef 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -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] diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index a6d1203d9d..7f2d8cf09f 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -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] diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 407e1c7c5a..f280e35fc6 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -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: 点击消息流中的工具行查看详情 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 6c0b20cc22..1227617de5 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -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] diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 1c0807b33b..c883524170 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -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] diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 5862e97ab6..2a5ecc7b14 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -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] diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index ed77fac08b..bfc7a2d267 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -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] diff --git a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md index 39bf528542..d69a95eb2d 100644 --- a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md @@ -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 diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index c0e64f7bf3..be5b958bf2 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -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] diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index c919fccec1..3e642bafa1 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -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] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 6faa2f01a3..a887bad8eb 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -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] diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 9b023c21d2..dc1bc657ad 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -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 () => { diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index ce0a6db799..8041df307d 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -32,6 +32,31 @@ export function probeFreePort(): Promise { }) } +/** + * 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 + * /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 { + 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 { const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts new file mode 100644 index 0000000000..ef710129d8 --- /dev/null +++ b/apps/web/tests/todo-display.snapshot.ts @@ -0,0 +1,191 @@ +// @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).__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 { + 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('[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') + + // Header spans are adjacent inline nodes; textContent joins "To-dos" + + // "1/3…" with no space (visual gap is CSS gap: 10px, not a text node). + 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": "To-dos1/3 tasks · 1 in progress", + "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 count summary 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": "To-dos1/3 tasks · 1 in progress", + "listGone": true, + } + `) + + fireEvent.click(header) + expect(panel.querySelectorAll('li')).toHaveLength(3) +}) diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index b0ff27d522..0166c6a9d9 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -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).__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 { + return await screen.findByPlaceholderText( + 'Choose a workspace to start', {}, { timeout: 10_000 }, + ) +} + +/** The live blank-session hero composer (session materialized). */ +async function findHeroComposer(): Promise { + 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 { + 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 { + 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('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", } `) }) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index a19211c6bf..98a2338064 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -5,12 +5,13 @@ // calls: workspace.create/rename are host RPCs with no model involvement, // and the one session row the flat/hover scenarios need comes from a seeded // fixture (the seeded-history seed reused verbatim — no new recording). -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -105,6 +106,203 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete')) + const slotConsoleErrors: string[] = [] + const transientSlotErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) { + slotConsoleErrors.push(message.text()) + } + }) + await page.exposeFunction('recordDshSlotError', (key: string) => { + if (!transientSlotErrors.includes(key)) transientSlotErrors.push(key) + }) + await page.evaluate(() => { + const target = window as unknown as { recordDshSlotError(key: string): Promise } + const seen = new Set() + const collect = (): void => { + for (const node of document.querySelectorAll('[data-slot-error]')) { + const key = node.dataset.slotError ?? '' + if (!seen.has(key)) { + seen.add(key) + void target.recordDshSlotError(key) + } + } + } + new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true }) + collect() + }) + // Register the scaffold's existing project directory through the real UI. + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const useFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) + await useFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) + await useFolder.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => useFolder.count(), { timeout: 10_000 }).toBe(0) + + const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) + if (workspace === undefined) throw new Error('GUI did not register the existing project directory') + await workspace.attachSession(SessionId(SEED_ID)) + const header = (await scaffold.ctx.sessionPersistence.list()) + .find(candidate => candidate.id === SEED_ID) + if (header === undefined) throw new Error('seeded Session log disappeared before deletion') + const logLocation = scaffold.ctx.sessionPersistence.locate(header) + if (logLocation === undefined) throw new Error('JSONL persistence did not expose the seeded log path') + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + + // Open the seeded (first/accounted) Session so deletion must preserve the + // current selection while it moves into Ungrouped. + const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first() + await groupRow.waitFor({ timeout: 10_000 }) + const groupSection = groupRow.locator('..') + if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click() + await expect.poll( + () => groupSection.locator('[role="treeitem"]').count(), + { timeout: 10_000 }, + ).toBeGreaterThanOrEqual(2) + const seededRow = groupSection.locator('[role="treeitem"]').nth(1) + await seededRow.click() + await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true') + + await groupRow.hover() + await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click() + await page.getByRole('menuitem', { name: 'Delete workspace' }).click() + const dialog = page.getByRole('dialog', { name: 'Delete workspace' }) + await dialog.waitFor({ timeout: 10_000 }) + const copy = await dialog.textContent() + expect(copy).toContain('workspace list') + expect(copy).toContain('folder and session logs will be kept') + expect(copy).toContain('sessions will appear under Ungrouped') + await dialog.getByRole('button', { name: 'Delete workspace' }).click() + await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) + + expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined() + await expect.poll( + () => page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).count(), + { timeout: 10_000 }, + ).toBe(0) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }) + .toBeGreaterThanOrEqual(1) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 10_000 }, + ).toBe(1) + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + + // Re-registering the exact deleted path immediately, without a reload, is + // a supported reversible flow. It creates a fresh Workspace id without + // re-adopting the retained Session. + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) + await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) + await reuseFolder.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0) + const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) + expect(reregistered?.id).toBeDefined() + expect(reregistered?.id).not.toBe(workspace.id) + expect(reregistered?.sessionIds).toEqual([]) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }) + .toBeGreaterThanOrEqual(1) + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + + // Restore the deleted-registry state so reload still verifies deletion + // persistence independently of the successful re-registration above. + if (reregistered === undefined) throw new Error('same-path re-registration did not materialize') + await scaffold.ctx.workspace.delete(reregistered.id) + await expect.poll( + () => page.getByRole('button', { name: `Workspace actions for ${reregistered.title}` }).count(), + { timeout: 10_000 }, + ).toBe(0) + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 15_000 }, + ).toBe(1) + expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined() + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + + expect(transientSlotErrors).toEqual([]) + expect(slotConsoleErrors).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + + it('reuses a deleted title for a different new directory without any transient error surface', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-reuse-title')) + const title = 'same-name' + const oldPath = join(scaffold.workspaceCwd, 'adopted', title) + await mkdir(oldPath, { recursive: true }) + const transientErrors: string[] = [] + const consoleErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + await page.exposeFunction('recordDshTransientWorkspaceError', (message: string) => { + if (!transientErrors.includes(message)) transientErrors.push(message) + }) + await page.evaluate(() => { + const target = window as unknown as { + recordDshTransientWorkspaceError(message: string): Promise + } + const collect = (): void => { + for (const node of document.querySelectorAll('[data-slot-error], [role="alert"]')) { + const message = node.dataset.slotError ?? node.textContent?.trim() ?? '' + if (message !== '') void target.recordDshTransientWorkspaceError(message) + } + } + new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true }) + collect() + }) + + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const adopt = page.getByRole('dialog', { name: 'Use an existing folder' }) + await adopt.getByLabel('Existing folder path').fill(oldPath) + await adopt.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0) + const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath) + if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered') + + const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first() + await oldRow.hover() + await page.getByRole('button', { name: `Workspace actions for ${title}` }).click() + await page.getByRole('menuitem', { name: 'Delete workspace' }).click() + await page.getByRole('dialog', { name: 'Delete workspace' }) + .getByRole('button', { name: 'Delete workspace' }).click() + await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined() + + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() + const create = page.getByRole('dialog', { name: 'Create a new workspace' }) + await create.getByLabel('New workspace name').fill(title) + await create.getByRole('button', { name: 'Create workspace' }).click() + await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0) + const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title) + expect(fresh?.id).toBeDefined() + expect(fresh?.id).not.toBe(oldWorkspace.id) + expect(fresh?.path).toBe(join(scaffold.workspaceCwd, title)) + expect(transientErrors).toEqual([]) + expect(consoleErrors).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('switches to the flat "In one list" view and persists the preference', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-flat')) // Grouped default: workspace group rows render (the seeded session sits @@ -134,12 +332,20 @@ describe('web e2e: workspace management (create / rename / flat view / hover car onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) // Expand Ungrouped to reveal the seeded session row, then dwell on it // (the card opens after a 500ms hover delay, portaled to body). - await page.getByText('Ungrouped', { exact: true }).click() - // A cold summary carries no durable title, so the row falls back to a - // cwd-derived display title — anchored on the run-local workspace-root - // basename rather than a literal. - const wsBase = scaffold.workspaceCwd.split('/').pop()! - const sessionRow = page.locator('[role="treeitem"]').filter({ hasText: wsBase }).first() + const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + const ungroupedSection = ungroupedRow.locator('..') + // Initial-current auto-expansion can race this following test's gesture; + // converge on expanded rather than assuming which update wins first. + await expect.poll(async () => { + if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { + await page.getByText('Ungrouped', { exact: true }).click() + await page.waitForTimeout(50) + } + return await ungroupedRow.getAttribute('aria-expanded') + }, { timeout: 5_000 }).toBe('true') + // The only visible child is the non-blank persisted Session; the blank + // Session created while adopting the Workspace remains hidden. + const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.hover() // Card content: the full title plus the Idle status line (display-only diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 1798b46908..e641d6ea2f 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index b426891c04..34e56dd955 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 13feefb685..ea105b869e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -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)。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4a6f3669e2..fb96ff101a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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 /** 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` @@ -1656,7 +1657,7 @@ Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tas Requires: `tools` · `web` · `systemPrompt` ```ts config-catalog -/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */ +/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */ export interface Config { /** Register `web_search`. Defaults to true. */ search?: boolean @@ -1668,10 +1669,12 @@ export interface Config { fetchTimeoutMs?: number /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */ searchTimeoutMs?: number + /** Cap on source characters converted and complete `web_fetch` output characters. Defaults to 200000. */ + fetchMaxOutputChars?: number } ``` -Source: [`packages/web/tool-web/src/index.ts:29`](../packages/web/tool-web/src/index.ts) +Source: [`packages/web/tool-web/src/index.ts:35`](../packages/web/tool-web/src/index.ts) ## `@deepseek-ai/dsh-tool-workflow` @@ -1781,7 +1784,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 +2050,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 +2058,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)) diff --git a/docs/cookbook/adding-an-llm-adapter.i18n.yaml b/docs/cookbook/adding-an-llm-adapter.i18n.yaml index 497ae08c32..c37bf85c1d 100644 --- a/docs/cookbook/adding-an-llm-adapter.i18n.yaml +++ b/docs/cookbook/adding-an-llm-adapter.i18n.yaml @@ -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 diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index f20442b8c2..a7f9dced70 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -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 diff --git a/docs/cookbook/adding-an-llm-adapter.zh.md b/docs/cookbook/adding-an-llm-adapter.zh.md index 2864dd1e18..3515927585 100644 --- a/docs/cookbook/adding-an-llm-adapter.zh.md +++ b/docs/cookbook/adding-an-llm-adapter.zh.md @@ -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 无需与其协议表示相同。 ## 经验证有效的结构 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4c97fdc72d..ef2f196dbf 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cc21210f3b..6785e5f983 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -730,33 +730,57 @@ listProviders(): LlmProviderInfo[] async listModels(provider: string): Promise /** - * 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 +async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ): Promise + +/** + * 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 + +/** + * 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 /** * 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 ``` -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` @@ -2015,6 +2039,16 @@ get(id: WorkspaceId): Workspace | undefined */ list(): Workspace[] +/** + * Delete one workspace registration while retaining its directory and every + * session log. The durable order is updated before the table deletion; a + * failed table write restores the prior order and keeps the entity + * published. Unknown ids are an idempotent no-op for domain callers. + * @param id - Workspace registration to remove. + * @returns `true` when a record was deleted, `false` when it was unknown. + */ +delete(id: WorkspaceId): Promise + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 47310dd09b..9b17460460 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 781267cccd..86aea325f5 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -199,7 +199,7 @@ interface LlmModelInfo { } ``` -Correctness-sensitive model capacity is queried separately from the advisory catalog and is owned by the adapter serving the exact route. +Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity and reasoning choices share one exact-model result so consumers do not repeat authoritative model resolution. ```ts type-equiv /** Provider-owned context capacity for one exact provider/model route. */ @@ -209,12 +209,56 @@ interface LlmModelContext { } ``` +Reasoning effort is another exact-route capability. The core brands identifiers but does not enumerate their values; each adapter owns the ordered set, display names, and optional deployment default. + +```ts type-equiv +/** Adapter-owned identifier for one model's selectable reasoning effort. */ +type ReasoningEffortId = Branded<'ReasoningEffortId'> +``` + +```ts type-equiv +/** Display metadata for one adapter-owned reasoning effort. */ +interface LlmReasoningEffortInfo { + /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */ + id: ReasoningEffortId + /** Human-readable effort name for selectors and diagnostics. */ + name: string + /** Optional user-facing distinction from otherwise similar efforts. */ + description?: string +} +``` + +```ts type-equiv +/** Selectable reasoning efforts for one exact provider/model route. */ +interface LlmModelReasoningInfo { + /** Supported efforts in adapter-preferred display order. */ + efforts: readonly LlmReasoningEffortInfo[] + /** + * Adapter-configured default materialized into requests when callers omit + * an effort. Absence preserves the provider's own default. + */ + defaultEffort?: ReasoningEffortId +} +``` + +```ts type-equiv +/** Exact-route model metadata resolved by its owning adapter. */ +interface LlmResolvedModelInfo extends LlmModelInfo { + /** Provider-owned context capacity when known. */ + context?: LlmModelContext + /** Adapter-owned selectable reasoning levels when exposed. */ + reasoning?: LlmModelReasoningInfo +} +``` + ```ts type-equiv /** A single model request, fully assembled. */ interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string + /** Adapter-owned reasoning effort selected for this exact model. */ + reasoningEffort?: ReasoningEffortId /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as @@ -291,21 +335,23 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, or sampling. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. -FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. +FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). ```ts type-equiv /** - * Provider + model + sampling scalars of one conversation's requests. Every field maps - * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests - * from the logged header rather than accepting these per call. + * Provider, model, reasoning effort, and sampling scalars of one conversation's + * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; + * the loop builds requests from the logged header rather than accepting these + * per call. */ interface LlmCallConfig { provider: string model: string + reasoningEffort?: ReasoningEffortId temperature?: number maxTokens?: number stop?: string[] diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d0f67983b9..d09fb48419 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -205,7 +205,7 @@ interface LlmModelInfo { } ``` -对正确性敏感的模型容量与参考目录分开查询,并归服务该确切路由的适配器所有。 +对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。 ```ts type-equiv /** Provider-owned context capacity for one exact provider/model route. */ @@ -215,12 +215,56 @@ interface LlmModelContext { } ``` +推理强度是另一项针对确切路由的能力。核心为标识符添加品牌类型,但不枚举其值;有序集合、展示名称和可选的部署默认值均由各适配器持有。 + +```ts type-equiv +/** Adapter-owned identifier for one model's selectable reasoning effort. */ +type ReasoningEffortId = Branded<'ReasoningEffortId'> +``` + +```ts type-equiv +/** Display metadata for one adapter-owned reasoning effort. */ +interface LlmReasoningEffortInfo { + /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */ + id: ReasoningEffortId + /** Human-readable effort name for selectors and diagnostics. */ + name: string + /** Optional user-facing distinction from otherwise similar efforts. */ + description?: string +} +``` + +```ts type-equiv +/** Selectable reasoning efforts for one exact provider/model route. */ +interface LlmModelReasoningInfo { + /** Supported efforts in adapter-preferred display order. */ + efforts: readonly LlmReasoningEffortInfo[] + /** + * Adapter-configured default materialized into requests when callers omit + * an effort. Absence preserves the provider's own default. + */ + defaultEffort?: ReasoningEffortId +} +``` + +```ts type-equiv +/** Exact-route model metadata resolved by its owning adapter. */ +interface LlmResolvedModelInfo extends LlmModelInfo { + /** Provider-owned context capacity when known. */ + context?: LlmModelContext + /** Adapter-owned selectable reasoning levels when exposed. */ + reasoning?: LlmModelReasoningInfo +} +``` + ```ts type-equiv /** A single model request, fully assembled. */ interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string + /** Adapter-owned reasoning effort selected for this exact model. */ + reasoningEffort?: ReasoningEffortId /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as @@ -297,21 +341,23 @@ interface ToolSchema { 循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及会话前缀。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 -FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。 +FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 ```ts type-equiv /** - * Provider + model + sampling scalars of one conversation's requests. Every field maps - * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests - * from the logged header rather than accepting these per call. + * Provider, model, reasoning effort, and sampling scalars of one conversation's + * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; + * the loop builds requests from the logged header rather than accepting these + * per call. */ interface LlmCallConfig { provider: string model: string + reasoningEffort?: ReasoningEffortId temperature?: number maxTokens?: number stop?: string[] diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 5de51d2794..3d35da5691 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -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 -llm-streaming.md: fb97e74a9ec01e62ba940295112fb157cc73bd1d -llm-streaming.zh.md: fda59a64aeef1c037372d69dbc15ee0de48de222 +# pnpm run verify-translation-pairing --write docs/core-data-structures/llm-streaming.md +llm-streaming.md: 9151ba569af6b640144c1850d616b6f762b68a8b +llm-streaming.zh.md: ae4a6843b9bf4f09169b17271c7edea76f8f2051 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index fb97e74a9e..9151ba569a 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -67,7 +67,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). - **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. -This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. +This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (direct fetch, SSE framing via `eventsource-parser`) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. ## `AppIdentity` — app attribution @@ -157,14 +157,30 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). + +```ts type-equiv +/** One model call whose config and adapter registration were resolved together. */ +interface PreparedLlmCall { + /** Detached, deep-frozen config with any adapter-owned default materialized. */ + readonly config: LlmCallConfig + /** + * Dispatch this call once through the registration captured during + * preparation. The request's call-config fields must match {@link config}; + * reuse or mismatch fails with `INVALID_PREPARED_CALL`. + * @param options - fully assembled request carrying the prepared config. + * @returns the chunk stream, including the `llm/stream` waterfall. + */ + stream(options: GenerateOptions): AsyncIterable +} +``` ```ts public-api /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled - * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch + * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. */ declare abstract class LlmAdapter { /** @@ -182,16 +198,19 @@ declare abstract class LlmAdapter { */ listModels(_provider: string): Promise; /** - * Resolve context capacity for one model accepted by this adapter. Absence - * means the adapter does not know the capacity, not that routing is invalid. - * @param _provider - one provider route owned by this adapter. - * @param _model - exact model id passed to {@link GenerateOptions.model}. - * @returns provider-owned context metadata, or `undefined` when unavailable. + * Resolve all metadata available for one exact model. This query is + * independent of the advisory catalog and does not validate request routing. + * @param provider - one provider route owned by this adapter. + * @param model - exact model id passed to {@link GenerateOptions.model}. + * @param _signal - cancellation for this exact-model lookup; asynchronous + * implementations must settle promptly after it aborts. + * @returns provider/model identity plus any context and reasoning metadata. */ - resolveModelContext( - _provider: string, - _model: string, - ): Promise; + resolveModel( + provider: string, + model: string, + _signal?: AbortSignal, + ): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index fda59a64ae..ae4a6843b9 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -67,7 +67,7 @@ interface LlmFailure { - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 - **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。 -该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(手写 fetch/SSE(Server-Sent Events))和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 +该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(直接 fetch,SSE(Server-Sent Events)分帧经由 `eventsource-parser`)和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 ## `AppIdentity`:应用归属 @@ -157,14 +157,30 @@ declare class BlockAssembler { ## seam -`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单独的 `resolveModelContext()` 查询会暴露确切路由上对正确性敏感的容量信息,但不会让目录成员关系具有权威性;缺失表示元数据未知,而不是路由无效。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 + +```ts type-equiv +/** One model call whose config and adapter registration were resolved together. */ +interface PreparedLlmCall { + /** Detached, deep-frozen config with any adapter-owned default materialized. */ + readonly config: LlmCallConfig + /** + * Dispatch this call once through the registration captured during + * preparation. The request's call-config fields must match {@link config}; + * reuse or mismatch fails with `INVALID_PREPARED_CALL`. + * @param options - fully assembled request carrying the prepared config. + * @returns the chunk stream, including the `llm/stream` waterfall. + */ + stream(options: GenerateOptions): AsyncIterable +} +``` ```ts public-api /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled - * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch + * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. */ declare abstract class LlmAdapter { /** @@ -182,16 +198,19 @@ declare abstract class LlmAdapter { */ listModels(_provider: string): Promise; /** - * Resolve context capacity for one model accepted by this adapter. Absence - * means the adapter does not know the capacity, not that routing is invalid. - * @param _provider - one provider route owned by this adapter. - * @param _model - exact model id passed to {@link GenerateOptions.model}. - * @returns provider-owned context metadata, or `undefined` when unavailable. + * Resolve all metadata available for one exact model. This query is + * independent of the advisory catalog and does not validate request routing. + * @param provider - one provider route owned by this adapter. + * @param model - exact model id passed to {@link GenerateOptions.model}. + * @param _signal - cancellation for this exact-model lookup; asynchronous + * implementations must settle promptly after it aborts. + * @returns provider/model identity plus any context and reasoning metadata. */ - resolveModelContext( - _provider: string, - _model: string, - ): Promise; + resolveModel( + provider: string, + model: string, + _signal?: AbortSignal, + ): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 38ca48f9a0..1797724b9d 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -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 -session.md: 2cbbac8042d04522fea0b1ed7a66c503e4b63f4e -session.zh.md: e932c8f99f684f1b8985b006ab4ddb145db966bd +# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md +session.md: 59bcf027c3ec728c5583478c7d17bd6eef2dc2fa +session.zh.md: 01b6f080b5fcf7b83ca46b99a8d80113a6ebe8bf diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 2cbbac8042..59bcf027c3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -175,7 +175,7 @@ The request envelope — the `EpochHeader` (call config + rendered system prompt * canonical empty optional fields are absent. */ interface EpochHeader { - /** The conversation's call configuration (provider, model, and sampling scalars). */ + /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index e932c8f99f..01b6f080b5 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -177,7 +177,7 @@ interface TodoItem { * canonical empty optional fields are absent. */ interface EpochHeader { - /** The conversation's call configuration (provider, model, and sampling scalars). */ + /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index db97881ae8..8d19bd9880 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -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 -development.md: c46d84740e6f0a1f67158f39f9ea421cb57165d4 -development.zh.md: 9e13ab258e1db5406f84ece61959a995110578ae +# pnpm run verify-translation-pairing --write docs/development.md +development.md: fd7f39ae7b5aac2d44572979ca8c8f1d2df0de6f +development.zh.md: 7dd6209bad75d605e0056d2465a35b08aa091780 diff --git a/docs/development.md b/docs/development.md index c46d84740e..fd7f39ae7b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -112,6 +112,7 @@ pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling +pnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits) pnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale diff --git a/docs/development.zh.md b/docs/development.zh.md index 9e13ab258e..7dd6209bad 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -112,6 +112,7 @@ pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling +pnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits) pnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8bf4ee19b5..d042a9e17a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -12,9 +12,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | @@ -27,17 +27,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:474`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:53`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -61,6 +65,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | +| `commands/changed` | `runtime` (`emit`) | - | +| `connection/reset` | `runtime` (`emit`) | - | | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 52c5979c50..e4fb87a322 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: daddd35f981879b539ec76f0c21cf232f4688036 -README.zh.md: c36728240690edb7ae35f33eaa7595a5d320860f +# pnpm run verify-translation-pairing --write docs/i18n/README.md +README.md: 9fe01174909f0400b94c70b95759526aec783d2e +README.zh.md: 2a9a20ae4702e205879f0fbfb6d76d3a761d2446 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index daddd35f98..9fe0117490 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -15,7 +15,7 @@ This repo's documentation is read by people and agents both inside and outside t foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p `), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. After bringing the pair back in line, `pnpm run verify-translation-pairing --write` re-records both hashes; that yaml diff is the reviewable act of confirming consistency. + Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hashes also recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). - **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`. - **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). @@ -31,7 +31,9 @@ Source-oriented code gates consume an exact `.zh.md` fence sequence as a derivat `pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects. -The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. +`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level. + +The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write `), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index c367282406..2a9a20ae47 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -15,7 +15,7 @@ foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p `),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」,从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。 + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 - **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。 - **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 @@ -31,7 +31,9 @@ `pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。 -这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 +`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。 + +这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write `),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 diff --git a/docs/module-graph.md b/docs/module-graph.md index 3c687e6b16..69bcdfd4ee 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -141,6 +141,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_runtime["client-runtime"] + pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_models["client-ui-models"] @@ -149,7 +150,10 @@ flowchart TD pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_sidebar["client-ui-sidebar"] + pkg_client_ui_skill["client-ui-skill"] + pkg_client_ui_slash["client-ui-slash"] pkg_client_ui_slots["client-ui-slots"] + pkg_client_ui_subagent["client-ui-subagent"] pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_trajectory["client-ui-trajectory"] pkg_client_ui_workspace["client-ui-workspace"] @@ -256,10 +260,6 @@ flowchart TD pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots pkg_client_locale --> pkg_invariants - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_models --> pkg_client_runtime pkg_client_ui_models --> pkg_client_ui_slots pkg_client_ui_models --> pkg_invariants @@ -271,6 +271,9 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots @@ -301,12 +304,26 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_runtime pkg_client_ui_settings_general --> pkg_client_ui_primitives pkg_client_ui_settings_general --> pkg_client_ui_settings pkg_client_ui_settings_general --> pkg_client_ui_slots pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_theme --> pkg_client_locale pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives @@ -364,6 +381,13 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme @@ -855,10 +879,10 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | @@ -870,7 +894,10 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -889,6 +916,7 @@ flowchart TD | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 07956b019b..311791e594 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1137,7 +1137,7 @@ Record and update a structured task list for the current work. Send the ENTIRE l "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 8735e8d5a6..402e3537a7 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -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 -llm-adapter.md: 3e83289b8072ef231f83c0fa3cfe3260547b42fa -llm-adapter.zh.md: 92fcf9b22f4bb356ada4c46f9a03ef0cc2d159da +llm-adapter.md: 3bf005647d3e7908f1d1553374266c1d6c12c2e6 +llm-adapter.zh.md: b967d112ed6879b11486ab7aedf64653089d93de diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 3e83289b80..3bf005647d 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -110,7 +110,9 @@ async function* exampleChunks(): AsyncIterable { ## GenerateOptions -`stream()` receives the exported `GenerateOptions` type. It includes the model, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it. +`stream()` receives the exported `GenerateOptions` type. It includes the model, adapter-owned reasoning-effort id, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it. + +Override `resolveModel(provider, model, signal?)` to return exact provider/model identity plus optional `context` and `reasoning` metadata in one lookup. Reasoning metadata contains ordered opaque ids and display names plus an optional configured default; preserve the adapter's authoritative selectable list, including `off` when its upstream capability API returns it, instead of promoting those values into a core enum. Honor the optional signal for asynchronous lookup so cancellation and disposal reach quiescence. The service validates the aggregate and rejects unsupported explicit efforts before `stream()`; omitting `reasoning` means that model has no selectable reasoning-effort capability. ## Register an adapter diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 92fcf9b22f..b967d112ed 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -110,7 +110,9 @@ async function* exampleChunks(): AsyncIterable { ## GenerateOptions -`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 +`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、由适配器持有的推理强度 ID、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 + +请覆写 `resolveModel(provider, model, signal?)`,在一次查询中返回确切的提供方/模型身份以及可选的 `context` 和 `reasoning` 元数据。推理元数据包含有序的不透明 ID、展示名称,以及可选的配置默认值;请保留适配器给出的权威可选列表,包括其上游能力 API 返回的 `off`,而不要将这些值提升为核心枚举。异步查询必须响应这个可选信号,让取消和资源释放都能达到完全停稳。服务会校验聚合结果,并在调用 `stream()` 前拒绝显式指定但不受支持的推理强度;省略 `reasoning` 表示该模型没有可选的推理强度能力。 ## 注册适配器 diff --git a/examples/acp-agent/README.i18n.yaml b/examples/acp-agent/README.i18n.yaml index 22842fcd04..391967d91c 100644 --- a/examples/acp-agent/README.i18n.yaml +++ b/examples/acp-agent/README.i18n.yaml @@ -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 -README.md: 4b3d86b00613cc7c37a8898ef3b39d40a167e66b -README.zh.md: 5bcd85f2b4ae34a11b980bf196d3401f764004d8 +README.md: 0d63ec1f2d9165b9faf0817bd94fbe15b97fa961 +README.zh.md: 0c5f8866ea640843513fd9a4c15a17ed4db59d3b diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 4b3d86b006..0d63ec1f2d 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -9,7 +9,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport ``` -The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, while [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. +The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK, and [`web.cordis.yml`](web.cordis.yml) adds the web seam, the local fetch provider, `web_fetch`, and a loopback HTML fixture server for the web-fetch snapshot. ## Protocol channel diff --git a/examples/acp-agent/README.zh.md b/examples/acp-agent/README.zh.md index 5bcd85f2b4..0c5f8866ea 100644 --- a/examples/acp-agent/README.zh.md +++ b/examples/acp-agent/README.zh.md @@ -9,7 +9,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport ``` -该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 则添加 `run_code` 及其生成的 TypeScript SDK。 +该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK,[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture 服务器。 ## 协议通道 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index ca766a1f8e..4fb6f9493b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -40,6 +40,7 @@ const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) +const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -109,6 +110,12 @@ const SCENARIOS: Scenario[] = [ { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG }, + // web_fetch markdown rendering end to end: the overlay's loopback fixture + // server supplies deterministic HTML (entities, a GFM table, nesting), the + // REAL local fetch provider retrieves it, and the tool result pins the + // turndown conversion. The fetched URL (fixed port) is part of the recorded + // transcript; replay re-executes the real fetch against the same fixture. + { name: 'web-fetch', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'web', configPath: WEB_CONFIG }, { name: 'workspace-edit', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 0e42b0c6ab..3a37f3da6a 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -162,7 +162,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 285031a1de..1abccfd566 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -366,7 +366,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 055ad93065..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -145,7 +145,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index fa3eba25d8..b61d7bf623 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -309,7 +309,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 055ad93065..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -145,7 +145,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 055ad93065..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -145,7 +145,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 487f0517b1..e51933cdf3 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 5d27e93da3..9b5925605c 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -325,7 +325,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index e9f7a2ea63..8e093db8bd 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -417,7 +417,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index dde0ba0d7a..beb93c6b53 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -492,7 +492,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/web-fetch/input.json b/examples/acp-agent/tests/snapshots/web-fetch/input.json new file mode 100644 index 0000000000..dc1993235d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl new file mode 100644 index 0000000000..47c97b1cba --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl @@ -0,0 +1,31 @@ +{"type":"session","version":0,"id":"c12fa9af-1042-4a92-9ba4-4a968ff23495","createdAt":1785078727712,"cwd":"/tmp/acp-snap-cwd-hqkZWE","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785078727718,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785078727719,"data":{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785078727721,"data":{"title":"Use the web_fetch tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785078727730,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785078727731,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785078728804,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785078728805,"data":{"turn":1,"step":1,"index":0,"dt":[138,46,0,0,1,0,0,48,0,1,0,46,1,0,0,0,0,46,1,0,0,0,0,49,0,1,0,0,0,47,0,0,0,0,1,45,1,0,0,45,1,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," web","_f","etch"," tool"," exactly"," once"," to"," fetch"," http","://","127",".","0",".","0",".","1",":","431","17","/m","enu",".html",","," then"," reply"," with"," exactly"," \"","D","ONE","\"."," Let"," me"," do"," that","."]}} +{"type":"assistant/chunk","seq":50,"time":1785078729463,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":51,"time0":1785078729464,"data":{"turn":1,"step":1,"index":1,"dt":[47,0,0,0,0,46,0,0,1,46,0,0,0,0,1,46,1,0,0,0,0,45,1],"id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","args":["","{","\"","url","\"",": ","\"","http","://","127",".","0",".","0",".","1",":","431","17","/m","enu",".html","\"","}"]}} +{"type":"assistant/chunk","seq":75,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."}}}} +{"type":"assistant/chunk","seq":76,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}}}} +{"type":"assistant/chunk","seq":77,"time":1785078729803,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}}}} +{"type":"assistant/chunk","seq":78,"time":1785078729804,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":79,"time":1785078729807,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."},{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"} +{"type":"tool/call","seq":80,"time":1785078729809,"data":{"turn":1,"step":1,"callId":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}} +{"type":"tool/result","seq":81,"time":1785078729843,"data":{"turn":1,"step":1,"callId":"call_00_sxjOyfDYN07koiE7jiIa5326","content":[{"type":"text","text":"Fetched http://127.0.0.1:43117/menu.html (HTTP 200)\n\nMenu\n\n# Café menu\n\nPrices include **service & _tax_** — updated daily.\n\n- Espresso\n- Flat white\n\n| Drink | Price |\n| --- | --- |\n| Espresso | €2 |\n| Flat white | €3 |\n\nSee [today’s specials](https://fixture.invalid/specials)."}],"isError":false},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"step/end","seq":82,"time":1785078729847,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":83,"time":1785078729848,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":84,"time":1785078730611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":85,"time0":1785078730612,"data":{"turn":1,"step":2,"index":0,"dt":[158,54,1,0,0,36,1,47,47,46,1,0,0,47,0,0,0,0,1,46,43,1,0,0,48,0,46,0,0,0],"texts":["The"," user"," asked"," me"," to"," fetch"," the"," URL",","," then"," reply"," with"," exactly"," \"","D","ONE","\"."," I","'ve"," fetched"," it","."," Now"," I"," just"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":116,"time":1785078731235,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":117,"time":1785078731236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":118,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":119,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":120,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":121,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":239,"outputTokens":34,"cacheReadTokens":5376,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":122,"time":1785078731282,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":123,"time":1785078731283,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":239,"outputTokens":34,"cacheReadTokens":5376,"reasoningTokens":31}},"sourceEventSeqs":[84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122],"surfaceOp":"append"} +{"type":"step/end","seq":124,"time":1785078731286,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":125,"time":1785078731286,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md new file mode 100644 index 0000000000..45705db0a5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md @@ -0,0 +1,27 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-pro model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for example a result from web_search). It returns the page content decoded to text. Cite the URL as a markdown link when you use its content. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json new file mode 100644 index 0000000000..70940f8907 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -0,0 +1,489 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "web_fetch", + "description": "Fetch the content of a specific HTTP(S) URL and return it decoded to text.", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The HTTP(S) URL to fetch." + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/web-fetch-fixture-server.mjs b/examples/acp-agent/web-fetch-fixture-server.mjs new file mode 100644 index 0000000000..1667562faf --- /dev/null +++ b/examples/acp-agent/web-fetch-fixture-server.mjs @@ -0,0 +1,55 @@ +/** + * Deterministic loopback HTTP fixture for the web-fetch snapshot scenario: a + * small HTML page (headings, named entities, a GFM table, nested formatting) + * on a fixed port, so recording and keyless replay drive the REAL + * `dsh-web-fetch-local` transport and `dsh-tool-web` markdown rendering + * without external network. The port is fixed because the fetched URL is part + * of the recorded model transcript. + */ +import { createServer } from 'node:http' + +/** Fixed loopback port the scenario prompt points `web_fetch` at. */ +const PORT = 43117 + +const PAGE = ` +Menu + +

    Café menu

    +

    Prices include service & tax — updated daily.

    +
    • Espresso
    • Flat white
    +
    DrinkPrice
    Espresso€2
    Flat white€3
    +
    + +` + +/** Cordis plugin name. */ +export const name = 'web-fetch-fixture-server' + +/** + * Start the fixture server on 127.0.0.1 and register its shutdown. + * @param ctx - Cordis context; the effect disposes the server with the fiber. + */ +export async function apply(ctx) { + const server = createServer((req, res) => { + if (req.url === '/menu.html') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(PAGE) + return + } + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + res.end('not found') + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(PORT, '127.0.0.1', () => resolve(undefined)) + }) + // The fixture must never hold the process open past protocol shutdown. + server.unref() + ctx.effect(() => async () => { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve(undefined)) + // Stop accepting first so a connection cannot arrive after the forced close. + server.closeAllConnections() + }) + }, 'web-fetch-fixture-server') +} diff --git a/examples/acp-agent/web.cordis.snapshot.yml b/examples/acp-agent/web.cordis.snapshot.yml new file mode 100644 index 0000000000..015e67e221 --- /dev/null +++ b/examples/acp-agent/web.cordis.snapshot.yml @@ -0,0 +1,31 @@ +# Keyless replay counterpart to web.cordis.yml: the web stack and loopback +# fixture server stay real (the tool call re-executes the actual HTTP fetch and +# markdown rendering); only the model adapter is replaced by replay. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: web + name: '@deepseek-ai/dsh-web' + - id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + - id: web-fetch-fixture + name: './web-fetch-fixture-server.mjs' + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + search: false + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/web.cordis.yml b/examples/acp-agent/web.cordis.yml new file mode 100644 index 0000000000..1ed0b3efba --- /dev/null +++ b/examples/acp-agent/web.cordis.yml @@ -0,0 +1,21 @@ +# Web-fetch composition for the web-fetch snapshot scenario: the web seam, the +# real local HTTP fetch provider, the model-facing web tools (fetch only, so +# the pinned header carries exactly the surface under test), and the loopback +# fixture server the scenario prompt fetches — deterministic content, no +# external network, in recording and replay alike. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: web + name: '@deepseek-ai/dsh-web' + - id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + - id: web-fetch-fixture + name: './web-fetch-fixture-server.mjs' + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + search: false diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 5238e67374..0f0b02c1d9 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -1,8 +1,33 @@ import type { Context } from 'cordis' -import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { + CallId, + LlmAdapter, + ReasoningEffortId, + type GenerateOptions, + type LlmResolvedModelInfo, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' + +const HIGH = ReasoningEffortId('high') +const OFF = ReasoningEffortId('off') /** Keyless headless-agent adapter: one real bash call followed by a final answer. */ class CliMockAdapter extends LlmAdapter { + override async resolveModel(provider: string, model: string): Promise { + return { + provider, + id: model, + name: model, + reasoning: { + efforts: [ + { id: OFF, name: 'Off' }, + { id: HIGH, name: 'High' }, + ], + defaultEffort: HIGH, + }, + } + } + async * stream(options: GenerateOptions): AsyncIterable { const toolResult = options.messages.at(-1)?.content.find(block => block.type === 'tool-result') if (toolResult === undefined) { @@ -34,4 +59,8 @@ export const inject = ['llm'] /** Register the keyless `cli-mock` adapter. */ export function apply(ctx: Context): void { ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) + ctx.on('agent/request', async (_agent, _turn, step, _config, _signal, next) => { + const config = await next() + return step === 2 ? { ...config, reasoningEffort: OFF } : config + }) } diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 6852fed20b..f474cb5460 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -28,6 +28,7 @@ const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -124,6 +125,46 @@ async function persistedLogs(cwd: string): Promise { } describe('headless stream-json snapshots', () => { + it('logs the model default and a dynamic next-step reasoning effort', async () => { + const result = await runLoaderSmoke({ + label: 'reasoning effort headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-reasoning-effort-', + binScript, + configPath: reasoningConfigPath, + binArgs: ['--config', reasoningConfigPath, '--output-format', 'stream-json', 'prove dynamic reasoning effort'], + tsconfigPath, + }) + + expect(result.stderr).toBe('') + const headers = parseJsonl(result.stdout) + .map(record => record.event) + .filter((event): event is JsonObject => ( + event !== null + && typeof event === 'object' + && !Array.isArray(event) + && 'type' in event + && event.type === 'request/header' + )) + .map((event) => { + const data = event.data as JsonObject + return (data.header as JsonObject).config + }) + expect(headers).toMatchInlineSnapshot(` + [ + { + "model": "cli-mock", + "provider": "cli-mock", + "reasoningEffort": "high", + }, + { + "model": "cli-mock", + "provider": "cli-mock", + "reasoningEffort": "off", + }, + ] + `) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('replays the advanced toolchain through the one-shot app', async () => { const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const fixtureFiles = [ diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 045b9bb736..6cae860e36 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 8193973bee..c00a4119c7 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/package.json b/examples/package.json index 8a81d399b8..b0938c92c1 100644 --- a/examples/package.json +++ b/examples/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", + "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", diff --git a/examples/tui-agent/README.i18n.yaml b/examples/tui-agent/README.i18n.yaml index 631848bb77..3d29530411 100644 --- a/examples/tui-agent/README.i18n.yaml +++ b/examples/tui-agent/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: fdf3972f98f8ff9690b71469ae415dc18d339f10 -README.zh.md: 71f7ae949d034757a20adfae2cbe566011edc584 +# pnpm run verify-translation-pairing --write examples/tui-agent/README.md +README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 +README.zh.md: b3f6dc18536b159379eac7433367ccf2cd8fcc53 diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index fdf3972f98..ea8695d37e 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -19,7 +19,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. ### Resuming a prior session @@ -53,7 +53,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads | Entry | Demonstrates | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it depends on the Loader's internal module access | -| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | +| `llm-deepseek` | the default native adapter | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | | `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the pi-tui channel + a pre-created `main` agent | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md index 71f7ae949d..b3f6dc1853 100644 --- a/examples/tui-agent/README.zh.md +++ b/examples/tui-agent/README.zh.md @@ -19,7 +19,7 @@ pnpm run demo:tui `todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 -TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 和 Enter,或使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 +TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 ### 恢复早先的会话 @@ -53,7 +53,7 @@ pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay | 配置项 | 演示内容 | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | -| `llm-deepseek` | 通过配置提供真实 `LlmAdapter`(`!!js process.env.…` 密钥);将一行替换为 `@deepseek-ai/dsh-llm-pi-ai` 即可使用库后端对照实现 | +| `llm-deepseek` | 默认原生适配器 | | `bash` (`dsh-bash-local`) | 执行器实现:bash seam 的可替换一半。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | | `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent | | `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent | diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index c3bdbfd1b1..d3e35b6ce5 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -1,6 +1,11 @@ import type { Context } from 'cordis' -import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelInfo, + LlmResolvedModelInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}` @@ -35,8 +40,28 @@ class ScriptedTuiAdapter extends LlmAdapter { ]) } - override resolveModelContext(_provider: string, _model: string): Promise { - return Promise.resolve({ contextWindow: 128_000 }) + override resolveModel( + provider: string, + model: string, + ): Promise { + return Promise.resolve({ + provider, + id: model, + name: model === 'tui-scripted-model-pro' ? 'Scripted Pro' : 'Scripted Base', + context: { contextWindow: 128_000 }, + ...model !== 'tui-scripted-model-pro' + ? {} + : { + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }, + }) } override async * stream(options: GenerateOptions): AsyncIterable { @@ -47,8 +72,12 @@ class ScriptedTuiAdapter extends LlmAdapter { for (const chunk of textChunks(TITLE_TEXT)) yield chunk return } - if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) { - throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables') + if ( + options.model !== 'tui-scripted-model-pro' + || !options.system?.includes('tui-scripted-model-pro') + || options.reasoningEffort !== ReasoningEffortId('max') + ) { + throw new Error('the scripted TUI request did not apply the selected model and reasoning effort') } const lastMessage = options.messages.at(-1) const lastText = (lastMessage?.content ?? []) diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 5967366c12..1cbe6c478e 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -103,7 +103,7 @@ function smoke(overrides: Partial & { label: string }): Prom // other route (see fixtures/tui-scripted-llm.ts). const SELECT_PRO_MODEL = [ { waitFor: 'scripted TUI ready.', send: '/model\r' }, - { waitFor: 'Select model', send: '\x1b[B\r' }, + { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { @@ -156,6 +156,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { ], }) expect(output).toContain('I need one decision before I continue.') + expect(output).toContain('Reasoning effort: Max.') expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.') expect(output).toContain('Leaving plan mode (applies from the next step).') expect(output).toContain('Default mode confirmed.') diff --git a/package.json b/package.json index 65899c1113..3aa3d6998a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", + "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "docs:dev": "pnpm --filter @deepseek-ai/website run dev", "docs:build": "pnpm --filter @deepseek-ai/website run build", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 1edaf9c7df..edc5b2e25d 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -9,6 +9,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, WorkspaceApi, WorkspaceId, WorkspaceView, + CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f5dc3ed199..9aa43145ed 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -6,7 +6,7 @@ // approval/question requests exercise replay and composer takeover with stable rpcIds. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, @@ -167,6 +167,22 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } + // Turn 65: todo_write sample — the TodoRow toolview in the flow plus the + // todo/write snapshot event feeding the TodoPanel plan strip. + const fixtureTodos = [ + { content: '梳理需求', status: 'completed' }, + { content: '实现 fixture 样本', status: 'in_progress' }, + { content: '浏览器验收', status: 'pending' }, + ] + const todoArgs = JSON.stringify({ todos: fixtureTodos }) + toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + // The real tool appends the snapshot mid-execution — between tool/call and + // tool/result — so the fixture reproduces that exact ordering (the last + // toolTurn events run ... tool/call, tool/result, step/end, turn/end). + const callIndex = events.length - 4 + const callTime = events[callIndex]?.time as number + events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } }) + events.forEach((e, i) => { e.seq = i }) return events as unknown as SessionEvent[] } @@ -281,6 +297,15 @@ function pageOf( return { events, hasMore: start > 0 } } +/** Current todo projection over the full log (host parallel: latest todo/write, last write wins). */ +function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined { + for (let i = log.length - 1; i >= 0; i--) { + const event = log[i] + if (event !== undefined && event.type === 'todo/write') return event.data.todos + } + return undefined +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -347,10 +372,11 @@ class FxInbox implements StreamConn { * @returns an ApiProxy backed entirely by in-memory state — no host process, no network. */ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { + // The resident fixture sessions all carry history, so none of them is blank. const sessions: SessionSummary[] = options.empty ? [] : [ - { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' }, - { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, - { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' }, + { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' }, + { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, + { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, ] const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) const nextTurn = new Map([[sid('fx-alpha'), 60]]) @@ -427,6 +453,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id) + /** Shared session guard for sessionId-addressed catalog routes: the error + * response when the session is unknown, undefined when it exists. */ + const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise> | undefined => { + if (summaryOf(request.payload.sessionId) !== undefined) return undefined + return err<{ sessionId: SessionId }, never>(request, { + code: 'session-not-found', + message: `no session ${request.payload.sessionId}`, + details: { sessionId: request.payload.sessionId }, + }) + } const setRunning = (id: SessionId, running: boolean): void => { const summary = summaryOf(id) if (summary === undefined || summary.running === running) return @@ -582,12 +618,13 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } } const created: SessionSummary = { - sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd, + sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, } sessions.push(created) attachedSessions += 1 const emitSession = (): void => { - emitHost({ type: 'host/session-added', sessionId: created.sessionId, cwd }) + // Mirrors the host: the frame fires at creation, so blank is constantly true. + emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd }) } if (workspace !== undefined && options.failWorkspaceAttach) { emitSession() @@ -607,12 +644,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const log = logs.get(request.payload.sessionId) ?? [] // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency). const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50) + // Tail page carries the session-level todo projection (host parallel: full-log backscan). + const todos = request.payload.beforeSeq === undefined ? backscanTodos(log) : undefined const doomed = failNextHistory failNextHistory = false const delay = historyDelayMs if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) if (doomed) throw new Error('fixture: simulated history transport failure') - return ok(request, page) + return ok(request, { ...page, ...todos === undefined ? {} : { todos } }) }, prompt: (request) => { const { sessionId: id, mode, content } = request.payload @@ -628,6 +667,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) } summary.updatedAt = Date.now() + // First accepted prompt appends events: the summary stops being blank. + summary.blank = false const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') if (mode === 'steer' && replays.has(id)) { // Steering: insert a steering message into the current turn; the replay continues. @@ -709,6 +750,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } return ok(request, { workspace: { ...workspace } }) }, + delete: (request) => { + const { workspaceId } = request.payload + const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) + if (index === -1) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + workspaces.splice(index, 1) + emitHost({ type: 'host/workspace-removed', workspaceId }) + return ok(request, { deleted: true as const }) + }, insertSessionBefore: (request) => { const { workspaceId, sessionId, beforeSessionId } = request.payload const workspace = workspaces.find(w => w.workspaceId === workspaceId) @@ -738,6 +793,52 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return ok(request, { workspace: { ...workspace } }) }, }, + commands: { + // The catalog mirrors one session's effective view (every fixture + // session has an agent, like the real host). + list: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + return ok(request, { + commands: [ + { name: 'compact', description: 'fixture:压缩当前会话上下文' }, + { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, + { name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '' } }, + ], + }) + }, + execute: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const line = request.payload.line.trim() + const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line) + const name = match?.[1] + if (name === 'compact' || name === 'echo') { + return ok(request, { + matched: true as const, + result: { kind: 'success' as const, text: name === 'echo' ? (match?.[2] ?? '') : 'fixture:已压缩(假动作)' }, + }) + } + if (name === 'goal-fixture') { + return ok(request, { + matched: true as const, + result: { kind: 'success' as const, text: `fixture:goal 已设置(${request.payload.sessionId})` }, + }) + } + return ok(request, { matched: false as const }) + }, + }, + skills: { + list: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + return ok(request, { + skills: [ + { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' }, + ], + }) + }, + }, events: { async *mux(_request, signal) { const conn = new FxInbox() @@ -854,7 +955,12 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) + case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) + case 'command.list': return this.api.commands.list(request) + // The in-memory execute never blocks, so a never-aborting signal is faithful here. + case 'command.execute': return this.api.commands.execute(request, new AbortController().signal) + case 'skill.list': return this.api.skills.list(request) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index eb074e5011..d4505eb659 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -14,6 +14,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, + CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index eecacc9581..1c58e604ab 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -2,7 +2,8 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId, + CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, + RpcRequest, RpcResponse, SessionId, SkillEntry, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -80,11 +81,30 @@ export class FakeApiClient implements IApiClient { rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), + delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), } + // Payloads stay `unknown` (lint-lane note above); response rows are the real + // wire shapes so cases can program catalogs and skill lists without casts. + onCommandList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ commands: [] })) + onCommandExecute: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ matched: false })) + onSkillList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ skills: [] })) + + readonly commands: IApiClient['commands'] = { + list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), + execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), + } + + readonly skills: IApiClient['skills'] = { + list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts new file mode 100644 index 0000000000..d3c62e736b --- /dev/null +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -0,0 +1,92 @@ +/** + * Fixture commands/skills domains: contract-shape conformance for the two + * domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute + * parse/dispatch, skill.list session resolution, and the FixtureApiClient + * dispatch rows. + */ +import { describe, expect, it } from 'vitest' +import type { SessionId } from '../src/client/api.ts' +import { RpcId } from '../src/client/api.ts' +import type { RpcRequest } from '../src/client/api.ts' +import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts' + +const sid = (id: string): SessionId => id as SessionId +let reqCount = 0 +const req =

    (payload: P): RpcRequest

    => ({ rpcId: RpcId(`t-${reqCount++}`), payload }) +const signal = new AbortController().signal + +describe('createFixtureApi commands/skills', () => { + it('serves the addressed session catalog with rpcId echo', async () => { + const api = createFixtureApi() + const request = req({ sessionId: sid('fx-alpha') }) + const response = await api.commands.list(request) + expect(response.rpcId).toBe(request.rpcId) + if (!response.result.ok) throw new Error('list failed') + const commands = response.result.value.commands + expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture']) + // input hint rides only the commands declaring it. + const echo = commands.find(c => c.name === 'echo') + expect(echo?.input?.hint).toBeTruthy() + expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined() + }) + + it('rejects a catalog request for an unknown session', async () => { + const api = createFixtureApi() + const response = await api.commands.list(req({ sessionId: sid('fx-nope') })) + expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + }) + + it('executes a known command line and reports matched with a result', async () => { + const api = createFixtureApi() + const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal) + if (!response.result.ok) throw new Error('execute failed') + expect(response.result.value.matched).toBe(true) + expect(response.result.value.result).toEqual({ kind: 'success', text: 'hello world' }) + }) + + it('addresses execute to the session (result text carries the id)', async () => { + const api = createFixtureApi() + const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal) + if (!hit.result.ok) throw new Error('execute failed') + expect(hit.result.value.matched).toBe(true) + expect(hit.result.value.result?.text).toContain('fx-alpha') + + const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + }) + + it('falls to matched:false on unknown names and non-command lines', async () => { + const api = createFixtureApi() + for (const line of ['/nope', 'plain text', '/']) { + const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal) + if (!response.result.ok) throw new Error('execute failed') + expect(response.result.value.matched).toBe(false) + expect(response.result.value.result).toBeUndefined() + } + }) + + it('serves the skill catalog for the addressed session and rejects unknown sessions', async () => { + const api = createFixtureApi() + const response = await api.skills.list(req({ sessionId: sid('fx-alpha') })) + if (!response.result.ok) throw new Error('skill list failed') + expect(response.result.value.skills[0]?.name).toBe('fixture-demo') + + const missingSession = await api.skills.list(req({ sessionId: sid('fx-nope') })) + expect(missingSession.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + }) +}) + +describe('FixtureApiClient command/skill dispatch', () => { + it('routes the three method keys through the in-memory dispatch table', async () => { + const client = new FixtureApiClient() + const list = await client.commands.list({ sessionId: sid('fx-alpha') }) + if (!list.result.ok) throw new Error('command.list failed') + expect(list.result.value.commands.length).toBeGreaterThan(0) + const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' }) + if (!executed.result.ok) throw new Error('command.execute failed') + expect(executed.result.value.matched).toBe(true) + const skills = await client.skills.list({ sessionId: sid('fx-alpha') }) + if (!skills.result.ok) throw new Error('skill.list failed') + expect(skills.result.value.skills.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 0baabaf617..ed5f88fb1d 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -71,6 +71,21 @@ describe('createFixtureApi', () => { expect(empty.result.value).toEqual({ events: [], hasMore: false }) }) + it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => { + const api = createFixtureApi() + const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) + if (!tail.result.ok) throw new Error('history failed') + const events = tail.result.value.events.map(e => e.event) + const todoAt = events.findIndex(e => e.type === 'todo/write') + expect(todoAt).toBeGreaterThan(0) + // Production ordering (the tool appends mid-execution): call → snapshot → result. + expect(events[todoAt - 1]?.type).toBe('tool/call') + expect(events[todoAt + 1]?.type).toBe('tool/result') + const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time) + expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0) + expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0) + }) + it('create adds a session and pushes host/session-added to open host streams', async () => { const api = createFixtureApi() const abort = new AbortController() @@ -87,7 +102,7 @@ describe('createFixtureApi', () => { await consuming if (!created.result.ok) throw new Error('create failed') const createdId = created.result.value.sessionId - expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, cwd: '/tmp/fixture' }]) + expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }]) const list = await api.sessions.list(req({})) if (!list.result.ok) throw new Error('list failed') expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true) @@ -365,6 +380,31 @@ describe('createFixtureApi', () => { expect(noop.result.value.workspace.updatedAt).toBe(before) }) + it('workspace.delete removes only the Workspace row and emits the removal frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) + expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) + await consuming + expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }]) + const list = await api.workspace.list(req({})) + if (!list.result.ok) throw new Error('workspace list failed') + expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false) + const sessions = await api.sessions.list(req({})) + if (!sessions.result.ok) throw new Error('session list failed') + expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha') + }) + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() const abort = new AbortController() @@ -384,7 +424,7 @@ describe('createFixtureApi', () => { await consuming // The session lands with the workspace's path as cwd, and the account // write pushes the fresh workspace snapshot after session-added. - expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, cwd: '/tmp/fixture' }) + expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' }) expect(seen[1]).toMatchObject({ type: 'host/workspace-changed', workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] }, @@ -413,7 +453,7 @@ describe('createFixtureApi', () => { expect(frames[0]).toMatchObject({ type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] }, }) - expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, cwd: made.result.value.workspace.path }) + expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path }) const retried = await api.sessions.create(req({ workspaceId: made.result.value.workspace.workspaceId, diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx index af33038970..2fdc5d5f45 100644 --- a/packages/client/locale/tests/language-row.spec.tsx +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -16,12 +16,12 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + { ids: [], byId: {}, current: undefined, phase: 'ready' }) return bindSnapshotSelector(store) } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 6ad7ad4e1f..d148c0dc5f 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 7776a5c2cf1d0990c9c339c6e5fc66401f935810 -README.zh.md: 8a0b7394c07878b8de958eae43d11203c92b5827 +# pnpm run verify-translation-pairing --write packages/client/runtime/README.md +README.md: a434b2d5719de2f30a883ee6e0d26264b3c62f4e +README.zh.md: a79fa99578e6bce4f0ff8e7c1f7538df8a436778 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 7776a5c2cf..a434b2d571 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,17 +2,19 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, page-local Workspace Intent state, default-target derivation, and the cross-object New Session flow. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync. ## Workspace and Session lists -Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. + +`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. -## Session creation failures +## New Session and the blank mirror -`SessionsService.create` accepts an optional caller-preallocated SessionId. It throws `SessionCreateError` on failure: `requestedSessionId` remains available after transport uncertainty, while `publishedSessionId` is set when `workspace-attach-failed` proves the Host published a real Session before attachment failed. For the New Session flow, the frontend Session object owns its retained prompt and advances it through attachment and send; a partially published Session keeps the same object and prompt while it appears as Ungrouped. +`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. ## Code Mode sub-dispatch index @@ -33,5 +35,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. -- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land. +- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 8a0b7394c0..a79fa99578 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,17 +2,19 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态和页面局部 Session Intent 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、页面局部 Workspace Intent 状态、默认目标派生,以及跨对象 New Session 流程。运行时把共享 Host 流分发给两个 manager。契约:api-contracts v3 §4。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值(host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`,因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失。 ## Workspace 与 Session 列表 -Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 + +`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 -## Session 创建失败 +## New Session 与 blank 镜像 -`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId。失败时抛出 `SessionCreateError`:传输状态不确定后仍可取得 `requestedSessionId`;如果 Host 在附加失败前已经发布真实 Session,则会设置 `publishedSessionId`,此时 `workspace-attach-failed` 提供了证明。在 New Session 流程中,前端 Session 对象拥有其保留的提示词,并推动提示词完成附加与发送;部分发布的 Session 会保留同一对象和提示词,同时显示为 Ungrouped。 +`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 ## Code Mode 子调用索引 @@ -33,5 +35,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 已知限制与暂缓事项 - **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。 -- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`cell()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 +- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 - **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。 diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts new file mode 100644 index 0000000000..af6fa3afcd --- /dev/null +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -0,0 +1,70 @@ +/** + * Client Agent-scope primitive: mint a Cordis context tagged with the owning + * Agent's identity. The mechanism mirrors the host `dsh-scope` architecture + * (no-op plugin fiber + context tag + `Context.filter` routing predicate); + * the shape deliberately diverges: the filter lives on the actx itself + * instead of a separate carrier object, so scoped dispatch is plain cordis — + * `actx.bail(actx, event, payload)` / `actx.emit(actx, ...)` — with no + * wrapper. The host needs a detached carrier because its dispatch subject is + * the business Agent object; client scope events carry only ids, so the + * actx is the natural subject. The second divergence stands: the scope key + * is the branded `SessionId` (value compared), not an object identity — the + * agent and its session share one id (1:1, same axis; no separate AgentId + * brand), and a client scope's identity IS that wire id. Third divergence, + * deliberate: the client scopes the Agent IDENTITY, not a live Agent object + * — a cold session's host Agent is already disposed while its client actx + * stays alive for history viewing. + */ +import { Context as CordisContext } from 'cordis' +import type { Context, Fiber } from 'cordis' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' + +/** Context tag written by {@link createScope}. */ +const kScope = Symbol('dsh.client.scope') + +/** A minted Agent scope and its disposal boundary. */ +export interface AgentScopeHandle { + /** + * Tagged context: scope-owned registrations and scoped dispatch both go + * through it (passing it as the dispatch subject routes to this agent's + * tagged listeners plus every untagged one). + */ + ctx: Context + /** Backing fiber (dispose tears down every scope-owned registration). */ + fiber: Fiber +} + +/** Shared no-op plugin backing each Agent scope fiber. */ +function agentScope(): void {} + +/** + * Mint an Agent scope under `ctx`: a no-op plugin fiber whose context + * carries the agent tag and the dispatch filter — untagged listeners are + * admitted globally, tagged listeners only for a matching agent. + * Registrations through the returned ctx dispose with the fiber. + * @param ctx - client root context the scope fiber mounts under. + * @param key - owning agent identity (the routing tag; agent id === session id). + * @returns the tagged context and its backing fiber. + */ +export function createScope(ctx: Context, key: SessionId): AgentScopeHandle { + const fiber = ctx.plugin(agentScope) + return { + fiber, + ctx: fiber.ctx.extend({ + [kScope]: key, + [CordisContext.filter](listenerCtx: Context): boolean { + const tag = scopeOf(listenerCtx) + return tag === undefined || tag === key + }, + }), + } +} + +/** + * Read the nearest agent tag inherited by a context. + * @param ctx - any client context. + * @returns its agent identity (the session id), or undefined for root contexts. + */ +export function scopeOf(ctx: Context): SessionId | undefined { + return (ctx as Context & { [kScope]?: SessionId })[kScope] +} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index ca059455e8..b4a418628f 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,7 +1,7 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' import { SessionsService } from './sessions/service.ts' import type { SessionListState } from './sessions/service.ts' @@ -11,10 +11,14 @@ import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './se export { SlotsService } from './slots.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' +export { createScope } from './agents/scope.ts' +export type { AgentScopeHandle } from './agents/scope.ts' export { WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' -export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' -export type { SessionIntentListSnapshot, SessionListPhase } from './sessions/manager.ts' +export type { + SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, +} from './sessions/service.ts' +export type { SessionListPhase } from './sessions/manager.ts' export type { WorkspaceListPhase } from './workspaces/manager.ts' export type { WorkspaceListState } from './workspaces/service.ts' export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' @@ -25,8 +29,8 @@ export type { } from './contract/store.ts' export type { AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, - ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, - SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, + ConversationSnapshot, QueuedMessage, RunningToolCall, + SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' @@ -56,6 +60,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** The framework-resolved session id (owners never pass it). */ sessionId: SessionId } + /** Standard kit for slots that remain mounted while current session changes. */ + interface SessionMaybeStandardProps { + useSession: MaybeSnapshotSelectorHook + /** Current session id; absent in the no-session state. */ + sessionId: SessionId | undefined + } /** Props injected into every global slot component. */ interface GlobalStandardProps { useSessions: SnapshotSelectorHook @@ -72,6 +82,20 @@ declare module 'cordis' { * @param key - the mutated SlotMap key. */ 'slots/changed'(key: string): void + /** + * The host command registry changed (host/commands-changed passthrough). + * Pure invalidation signal: subscribers refetch `command.list` in the + * background rather than diffing. + * @mode emit + */ + 'commands/changed'(): void + /** + * A connection generation was (re-)established. Wire-derived caches must + * treat their state as stale and repull (commands directory; the queue + * mirrors reset themselves through the session resync path). + * @mode emit + */ + 'connection/reset'(): void } interface Context { slots: import('./slots.ts').SlotsService @@ -91,15 +115,23 @@ export function apply(ctx: Context): void { const connection = ctx.get('connection') as ConnectionHandle const sessions = new SessionsService(ctx, connection.api) const workspaces = new WorkspacesService(ctx, connection.api, sessions) + ctx.effect( + () => workspaces.startInitialSelection(), + 'runtime: initial Workspace selection', + ) const loop = connection.start({ onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) }, onHostEnvelope: (envelope) => { sessions.handleHostEnvelope(envelope) workspaces.handleHostEnvelope(envelope) + // Typed-event bridge: the session layer ignores registry frames (no + // session routing); consumers (command directory caches) subscribe on ctx. + if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed') }, onConnected: () => { sessions.handleConnected() workspaces.handleConnected() + ctx.emit('connection/reset') }, }) ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 044f4aabdf..e1b2c42962 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,11 +4,14 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { - RpcError, SessionId, ToolCallView, ToolResultView, WorkspaceId, + RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' +export type { TodoItem } + /** Assistant content blocks sorted by what the UI cares about * (text body / collapsible reasoning / tool-call card head / other fallback). */ export type AssistantBlock = @@ -156,6 +159,12 @@ export interface RunningToolCall { } +/** One queued-message row mirrored from `session/queued` frames (key: the enqueueing prompt's rpcId when wire-sourced). */ +export interface QueuedMessage { + readonly key: string + readonly preview: string +} + /** In-progress assistant output (chunk accumulator product). */ export interface PartialAssistant { turn: number @@ -194,30 +203,6 @@ export interface PromptError { error: RpcError } -/** Workspace target of a frontend-only Session. */ -export type SessionIntentTarget = - | { kind: 'workspace'; workspaceId: WorkspaceId } - | { kind: 'workspace-intent' } - -/** Publication state owned by a frontend Session before it joins the Host. */ -export interface SessionIntentSnapshot { - target: SessionIntentTarget - phase: 'ready' | 'connecting' - error?: { step: 'session'; message: string } -} - -/** One editable prompt retained by its Session until the Host accepts it. */ -export interface PendingPrompt { - text: string - phase: 'editing' | 'sending' | 'failed' - /** Failed prerequisite retried before sending, or the send itself. */ - retry: 'connect' | 'send' - /** Workspace needed when retrying Session attachment. */ - workspaceId?: WorkspaceId - /** Last failure diagnostic, absent while editing or sending. */ - error?: string -} - /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId @@ -235,6 +220,8 @@ export interface ConversationSnapshot { */ codeDispatches: ReadonlyMap pending: readonly PendingInteraction[] + /** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */ + queue: readonly QueuedMessage[] running: boolean /** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */ composerPhase: ComposerPhase @@ -245,9 +232,19 @@ export interface ConversationSnapshot { hasMore: boolean loadingOlder: boolean promptError: PromptError | null - /** Frontend-only publication state; null for a Host-connected Session. */ - intent: SessionIntentSnapshot | null - /** Session-owned editable prompt waiting for connection, attachment, or send. */ - pendingPrompt: PendingPrompt | null + /** + * Whether this session still has an empty log (no user message yet). + * Mirrors the host summary's derived blank bit: seeded from `session.list` + * / the `host/session-added` frame, flipped false by the first ACCEPTED + * prompt locally (on the RPC success response — acceptance proves the + * user message is in the host log; a rejected first prompt keeps the + * session blank and reusable) and by any `running: true` status remotely, + * and re-aligned by every list re-pull (the summary stays authoritative). + * Blank sessions are hidden from session lists and reused by New Session. + */ + blank: boolean lastAgentError: string | null + /** Current whole-list `todo/write` projection — the tail page's full-log value, then each live + * write (last write wins); empty = the log holds no plan. */ + todos: readonly TodoItem[] } diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 3fd9af5d65..461d11660a 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -15,6 +15,8 @@ export interface SessionListEntry { title?: string updatedAt: number running: boolean + /** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */ + blank: boolean parentSessionId?: SessionId cwd?: string /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 51d07e70e6..694768ebbc 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -11,7 +11,6 @@ import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' import { Notifier } from './notifier.ts' import { Session } from './session.ts' -import type { SessionIntentSnapshot, SessionIntentTarget } from './conversation.ts' /** * List arrival lifecycle, orthogonal to the pull-activity `state` axis: @@ -23,19 +22,11 @@ import type { SessionIntentSnapshot, SessionIntentTarget } from './conversation. */ export type SessionListPhase = 'pending' | 'ready' -/** Session-owned frontend Intent projected into the global list snapshot. */ -export interface SessionIntentListSnapshot extends SessionIntentSnapshot { - sessionId: SessionId - prompt: string -} - /** Immutable session-list snapshot for useSessionList. */ export interface SessionListSnapshot { items: readonly SessionListEntry[] - /** Selected real or frontend-only Session id. */ + /** Selected Session id (validated against items; masked to undefined while its session is off the list). */ current: SessionId | undefined - /** Sole page-local frontend Session projection; its state remains owned by Session. */ - intent: SessionIntentListSnapshot | undefined state: 'idle' | 'loading' | 'error' /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */ phase: SessionListPhase @@ -46,6 +37,8 @@ type SessionListMutation = | { kind: 'upsert'; summary: SessionSummary } | { kind: 'remove'; sessionId: SessionId } | { kind: 'status'; sessionId: SessionId; running: boolean } + /** Local first-send flip: the sender clears blank without waiting for a host frame. */ + | { kind: 'engaged'; sessionId: SessionId } /** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */ const PENDING_BUFFER_CAP = 32 @@ -76,8 +69,6 @@ export class SessionManager { private listMutations: SessionListMutation[] | null = null private selected: SessionId | undefined - private intentSessionId: SessionId | undefined - private stopIntentWatch: (() => void) | undefined private listSnapshotCache: SessionListSnapshot /** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry @@ -101,87 +92,38 @@ export class SessionManager { this.listSnapshotCache = this.buildListSnapshot() } - // ---- Selection and client-local intents ---- + // ---- Selection ---- /** - * Select a real Session and discard the unmaterialized intent. - * @param sessionId - listed real Session id. + * Select a listed Session. + * @param sessionId - listed Session id. */ select(sessionId: SessionId): void { if (!this.summaries.some(summary => summary.sessionId === sessionId)) { throw new Error(`sessions.select: unknown session ${sessionId}`) } - this.discardIntent() this.selected = sessionId this.notifier.notifyNow() } - /** Clear selection and abandon any frontend-only Session. */ + /** Clear the selection (the layout falls to the no-session view state). */ clearSelection(): void { - this.discardIntent() this.selected = undefined this.notifier.notifyNow() } - /** - * Start a frontend Session against a real or still-local Workspace target. - * @param target - real Workspace or the WorkspacesService-owned local target. - * @param prompt - optional prompt retained when retargeting from a picker. - * @returns the frontend Session object that owns the Intent. - */ - startIntent(target: SessionIntentTarget, prompt = ''): Session { - this.discardIntent() - const sessionId = `client-session-${crypto.randomUUID()}` as SessionId - const session = this.createSession(sessionId, { target, prompt }) - this.sessions.set(sessionId, session) - this.intentSessionId = sessionId - this.selected = sessionId - this.stopIntentWatch = session.subscribe(() => { - if (this.intentSessionId !== sessionId) return - if (session.getSnapshot().intent === null) { - this.intentSessionId = undefined - this.stopIntentWatch?.() - this.stopIntentWatch = undefined - } - this.notifier.markDirty() - }) - this.notifier.notifyNow() - return session - } - - /** - * Resolve the active frontend Session Intent. - * @returns the active frontend Session, if one remains selected. - */ - getIntent(): Session | undefined { - return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId) - } - - /** - * Update the retained prompt of the active frontend Session. - * @param text - exact controlled-input value for the active frontend Session. - */ - updateIntent(text: string): void { - const session = this.getIntent() - if (session === undefined) return - session.updatePendingPrompt(text) - // The intent watch defers via markDirty, but the hero composer reads this - // prompt from the LIST snapshot as a controlled value: it must flush in - // the same tick as onChange (see Notifier.notifyNow) or React rolls the - // textarea back and IME composition breaks. - this.notifier.notifyNow() - } - - private discardIntent(): void { - const session = this.getIntent() - this.intentSessionId = undefined - this.stopIntentWatch?.() - this.stopIntentWatch = undefined - session?.abandonIntent() - } - // ---- Instance management ---- + /** + * Drop a session instance (scope-prune companion, decision 12: instance + * and scope share one lifecycle). The host session log is the durable + * truth — a later get() lazily rebuilds and open() backfills history. + * @param sessionId - the session to drop. + */ + drop(sessionId: SessionId): void { + this.sessions.delete(sessionId) + } + /** * Lazy build: return the existing instance or construct one (no auto-open — * open is triggered by the container's select callback). @@ -193,31 +135,33 @@ export class SessionManager { if (session === undefined) { session = this.createSession(sessionId) this.sessions.set(sessionId, session) - // Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open). - const summary = this.summaries.find(s => s.sessionId === sessionId) - if (summary !== undefined) session.handleRunning(summary.running) - // Replay approval/question frames buffered before instantiation (rpcId verbatim, same semantics as the subscribed baseline replay). + // Replay approval/question/queued frames buffered before instantiation (rpcId + // verbatim, same semantics as the subscribed baseline replay). Replay happens + // BEFORE the running-bit sync: a not-running summary must sweep replayed queue + // rows the same way a live status flip would (their retirement events dropped + // while the session was uninstantiated). const buffered = this.pendingBuffers.get(sessionId) if (buffered !== undefined) { this.pendingBuffers.delete(sessionId) for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload) } + // Sync the running and blank bits from the list snapshot into the new + // instance (consistency when the list precedes open). + const summary = this.summaries.find(s => s.sessionId === sessionId) + if (summary !== undefined) { + session.handleBlank(summary.blank) + session.handleRunning(summary.running) + } } return session } - private createSession( - sessionId: SessionId, - intent?: { target: SessionIntentTarget; prompt: string }, - ): Session { + private createSession(sessionId: SessionId): Session { return new Session(sessionId, this.api, { - ...(intent === undefined ? {} : { intent }), - onPublished: (published) => { - this.sessions.set(published.sessionId, published) - this.recordMutation({ - kind: 'upsert', - summary: { sessionId: published.sessionId, updatedAt: Date.now(), running: false }, - }) + // The sender's local first-send flip mirrors into the list row so the + // session surfaces (lists filter on blank) before any host frame lands. + onEngaged: (engaged) => { + this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) }, }) } @@ -244,8 +188,13 @@ export class SessionManager { this.summaries = summaries this.listState = 'idle' this.listPhase = 'ready' - // Push running bits down to instantiated Sessions (the list is the authoritative summary source). - for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running) + // Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source). + for (const s of this.summaries) { + const session = this.sessions.get(s.sessionId) + if (session === undefined) continue + session.handleBlank(s.blank) + session.handleRunning(s.running) + } } else { this.listState = 'error' this.listError = result.error @@ -266,7 +215,8 @@ export class SessionManager { /** * Contract session.create; on success merge into summaries immediately (no - * wait for the next refresh). + * wait for the next refresh). A created session is blank by definition + * (entity birth precedes the first message). * @param opts - target workspace or working directory, plus an optional caller-owned id. * @returns the create result. */ @@ -274,16 +224,14 @@ export class SessionManager { opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}, ): Promise> { try { + const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } const payload = opts.workspaceId !== undefined - ? { workspaceId: opts.workspaceId, ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }) } - : { - ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), - ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), - } + ? { workspaceId: opts.workspaceId, ...shared } + : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } const { result } = await this.api.sessions.create(payload) if (result.ok) { this.recordMutation({ kind: 'upsert', summary: { - sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, + sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), } }) } else { @@ -296,6 +244,7 @@ export class SessionManager { sessionId: publishedSessionId, updatedAt: Date.now(), running: false, + blank: true, } }) } } @@ -370,16 +319,31 @@ export class SessionManager { this.titleSnapshots.delete(frame.sessionId) this.notifier.markDirty() } + // New mux-generation baseline: buffered session/queued frames belong to + // the previous generation and the host is about to resend the live + // snapshot — drop them, or every reconnect appends a duplicate batch + // (and enough reconnects push real approval/question frames past the + // cap). Same re-baseline signal Session uses for its own mirror. + const buffered = this.pendingBuffers.get(frame.sessionId) + if (buffered !== undefined) { + const kept = buffered.filter(item => item.payload.type !== 'session/queued') + if (kept.length !== buffered.length) { + if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId) + else this.pendingBuffers.set(frame.sessionId, kept) + } + } } const session = this.sessions.get(frame.sessionId) if (session === undefined) { - // Approval/question frames never hit history: buffer for replay on instantiation; - // everything else drops (not instantiated — history fully backfills on open). + // Approval/question/queued frames never hit history: buffer for replay on + // instantiation; everything else drops (not instantiated — history fully + // backfills on open). switch (frame.type) { case 'approval/requested': case 'approval/resolved': case 'question/requested': - case 'question/resolved': { + case 'question/resolved': + case 'session/queued': { const buffer = this.pendingBuffers.get(frame.sessionId) ?? [] buffer.push(envelope) if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP) @@ -402,11 +366,11 @@ export class SessionManager { switch (frame.type) { case 'host/session-added': { this.mergeSummary({ - sessionId: frame.sessionId, updatedAt: Date.now(), running: false, + sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank, ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), }) - this.sessions.get(frame.sessionId)?.handlePublished() + this.sessions.get(frame.sessionId)?.handleBlank(frame.blank) return } case 'host/session-removed': { @@ -448,6 +412,7 @@ export class SessionManager { const prev = this.entryCache.get(entry.sessionId) if ( prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running + && prev.blank === entry.blank && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.title === entry.title && prev.depth === entry.depth ) return prev @@ -459,24 +424,13 @@ export class SessionManager { } const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i]) if (!sameOrder) this.itemsCache = items - const intentSession = this.getIntent() - const intentState = intentSession?.getSnapshot() - const intent = intentSession !== undefined - && intentState !== undefined && intentState.intent !== null && intentState.pendingPrompt !== null - ? { - sessionId: intentSession.sessionId, - ...intentState.intent, - prompt: intentState.pendingPrompt.text, - } - : undefined const selected = this.selected - const current = selected !== undefined && ( - intent?.sessionId === selected || items.some(item => item.sessionId === selected) - ) ? selected : undefined + const current = selected !== undefined && items.some(item => item.sessionId === selected) + ? selected + : undefined return { items: this.itemsCache, current, - intent, state: this.listState, phase: this.listPhase, error: this.listError, @@ -492,18 +446,29 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi if (existing === undefined) return [mutation.summary, ...summaries] const filled: SessionSummary = { ...existing, + // Blank only lowers: a stale true (session-added racing the local + // first send) never re-hides an already-surfaced session. + blank: existing.blank && mutation.summary.blank, ...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}), ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined ? { parentSessionId: mutation.summary.parentSessionId } : {}), } - if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId) return [...summaries] + if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId + && filled.blank === existing.blank) return [...summaries] return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) } case 'remove': return summaries.filter(summary => summary.sessionId !== mutation.sessionId) case 'status': - return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.running !== mutation.running - ? { ...summary, running: mutation.running } + // running:true doubles as the cross-端 blank flip (a blank session + // never runs, so the first running frame proves a message landed). + return summaries.map(summary => summary.sessionId === mutation.sessionId + && (summary.running !== mutation.running || (mutation.running && summary.blank)) + ? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running } + : summary) + case 'engaged': + return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank + ? { ...summary, blank: false } : summary) } } diff --git a/packages/client/runtime/src/client/sessions/notifier.ts b/packages/client/runtime/src/client/sessions/notifier.ts index b89904d727..f6f7a1cd49 100644 --- a/packages/client/runtime/src/client/sessions/notifier.ts +++ b/packages/client/runtime/src/client/sessions/notifier.ts @@ -3,11 +3,17 @@ // the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable // getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set // (keeps frame storms cheap); the next getSnapshot rebuilds lazily. +// +// Freshness and notification are SEPARATE bits: a pull (ensureFresh) between +// markDirty and the scheduled flush rebuilds the snapshot but must not +// swallow the notification — push subscribers (object-layer watchers) would +// otherwise starve whenever any reader pulls first. /** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */ export class Notifier { private listeners = new Set<() => void>() private dirty = false + private notifyPending = false private scheduled = false /** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */ @@ -28,14 +34,18 @@ export class Notifier { /** State-change entry: mark dirty and schedule the batched flush. */ markDirty(): void { this.dirty = true + this.notifyPending = true if (this.scheduled) return this.scheduled = true queueMicrotask(() => { this.scheduled = false - if (!this.dirty) return - if (this.listeners.size === 0) return // lazy: no subscribers, keep dirty for the next getSnapshot - this.dirty = false - this.rebuild() + if (!this.notifyPending) return + if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot + this.notifyPending = false + if (this.dirty) { + this.dirty = false + this.rebuild() + } for (const listener of this.listeners) listener() }) } @@ -46,13 +56,18 @@ export class Notifier { */ notifyNow(): void { this.dirty = true + this.notifyPending = true if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds + this.notifyPending = false this.dirty = false this.rebuild() for (const listener of this.listeners) listener() } - /** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). */ + /** + * Pre-getSnapshot check: rebuild synchronously when dirty (read path + * before first subscribe / while unobserved). Notification stays pending. + */ ensureFresh(): void { if (!this.dirty) return this.dirty = false diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 845292a481..ec8ddc2354 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -2,8 +2,9 @@ * SessionsService: root sessions service — list snapshot store (manager * projection; carries `current`, the persisted selection every * session-scoped surface keys off — migrated here from ui-layout per the - * slot-parity design), session scope tree (mintScope pattern: no-op plugin - * Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk. + * slot-parity design), Agent scope tree (mintScope pattern: no-op plugin + * Fiber + ctx.extend scope tag; one scope per session, agent id === session + * id), stable SessionBinding cache, ancestry walk. * * Scope lifecycle is stage-driven: a scope is minted lazily on first * resolution (pure — resolution has no side effects and is render-safe); @@ -16,15 +17,15 @@ */ import type { Context, Fiber } from 'cordis' import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots' +import type { + HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, +} from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' +import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' -import type { - SessionIntentListSnapshot, SessionListPhase, -} from './manager.ts' +import type { SessionListPhase } from './manager.ts' import type { Session } from './session.ts' -import type { SessionIntentTarget } from './conversation.ts' /** Session list row projected from the host list RPC plus live stream increments. */ export interface SessionSummary { @@ -36,6 +37,13 @@ export interface SessionSummary { cwd?: string parentId?: SessionId running: boolean + /** + * Empty-log bit (host summary derivation mirror). New Session reuses a blank + * one targeting the same workspace. Filtering stays with the consumer: the + * store carries every row, while the Workspace browser shows only the + * selected blank entry. + */ + blank: boolean updatedAt: number } @@ -48,17 +56,13 @@ export interface SessionListState { ids: SessionId[] byId: Record current: SessionId | undefined - /** Frontend Session Intent projected from its owning Session object. */ - intent: SessionIntentListSnapshot | undefined /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ phase: SessionListPhase } -/** Structured session-create failure preserving partial publication identity. */ +/** Structured session-create failure. */ export class SessionCreateError extends Error { override readonly name = 'SessionCreateError' - /** Definitely published by Host before Workspace attachment failed. */ - readonly publishedSessionId: SessionId | undefined /** * @param rpcError - Host business or folded transport error. @@ -69,9 +73,6 @@ export class SessionCreateError extends Error { readonly requestedSessionId: SessionId | undefined, ) { super(`session create failed: ${rpcError.code}: ${rpcError.message}`) - this.publishedSessionId = rpcError.code === 'workspace-attach-failed' - ? rpcError.details.sessionId - : undefined } } @@ -82,20 +83,10 @@ export interface SessionBinding { readonly ctx: Context } -/** Scope tag key (client counterpart of the host dsh-scope pattern). */ -const kScope = Symbol('dsh.client.scope') - -/** - * Read the session scope tag off a context. - * @param ctx - any client context. - * @returns the session id, or undefined on root contexts. - */ -export function scopeOf(ctx: Context): SessionId | undefined { - return (ctx as Context & { [kScope]?: SessionId })[kScope] -} - -/** Shared no-op plugin backing each session scope fiber. */ -function sessionScope(): void {} +// Scope primitives live in ../agents/scope.ts (the client mirror of host +// dsh-scope, keyed by Agent identity); re-exported here so existing +// consumers keep their import site. +export { scopeOf } from '../agents/scope.ts' /** * Workspace display title of a session cwd: the path's last non-empty @@ -128,8 +119,30 @@ interface ScopeRecord { fiber: Fiber ctx: Context binding: SessionBinding - /** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */ - cell: SessionCell + /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */ + provideInfo: SessionProvideInfo +} + +/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */ +export interface SessionProvideContribution { + /** Bare observable sources, keyed by hook base name ('input' → useInput). */ + hooks?: Record> + /** Stable plain members (action callbacks etc.), spread into standard props verbatim. */ + props?: Record +} + +/** + * Static declaration plus per-session resolver for one standard-kit + * contribution. The declared names let the renderer construct the same hook + * and prop surface while no session is current. + */ +export interface SessionProvideDescriptor { + /** Hook base names (`input` becomes `useInput`). */ + hooks?: readonly string[] + /** Plain standard-prop names. */ + props?: readonly string[] + /** Resolve every declared member for one definite session. */ + resolve(binding: SessionBinding): SessionProvideContribution } /** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */ @@ -150,6 +163,10 @@ export class SessionsService { private readonly selection: SnapshotStore<{ sessionId?: SessionId }> private readonly scopes = new Map() + /** Registered per-session standard-props providers, in registration order. */ + private readonly providers: SessionProvideDescriptor[] = [] + /** Static no-session projection, rebuilt only when the provider roster changes. */ + private maybeInfo: SessionMaybeProvideInfo /** * The staged session id — follows `list.current` exactly, holding its last * defined value across masked gaps (a transiently absent selection blanks @@ -170,7 +187,7 @@ export class SessionsService { { persist: { name: 'dsh.sessions.current' } }) this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId) this.list = createSnapshotStore({ - ids: [], byId: {}, current: undefined, intent: undefined, phase: 'pending', + ids: [], byId: {}, current: undefined, phase: 'pending', }) // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. @@ -182,9 +199,97 @@ export class SessionsService { // the follower writes no list state — session.open()'s synchronous prefix // touches only session-side state and its own microtask-batched notifier. this.list.subscribe(() => { this.followCurrent() }) + // The runtime's own contribution comes first: useSession rides the same + // provide channel every plugin uses (no renderer special case). + this.providers.push({ + hooks: ['session'], + resolve: binding => ({ hooks: { session: binding.session } }), + }) + this.maybeInfo = this.materializeMaybeProvideInfo() rootCtx.reflect.provide('sessions', this, undefined) } + /** + * Register a per-session standard-props provider: every session-scope slot + * component receives the contributed members as standard props (`hooks` + * sources become `use` selector hooks on the render side; `props` + * spread verbatim). Contributions materialize lazily with the session's + * scope record and die with it. Registration order is resolution order; + * duplicate member names fail loud at materialization. + * @param descriptor - static member roster plus per-session resolver. + * @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops). + */ + provide(descriptor: SessionProvideDescriptor): () => void { + this.providers.push(descriptor) + // Scopes may already exist (boot order: the list lands and resolves + // scopes before later plugins register) — their bundles must include + // every provider by first render, so re-materialize on roster change. + this.rematerializeProvideBundles() + return () => { + const at = this.providers.indexOf(descriptor) + if (at >= 0) this.providers.splice(at, 1) + this.rematerializeProvideBundles() + } + } + + /** Rebuild every live scope's standard-props bundle after a provider roster change. */ + private rematerializeProvideBundles(): void { + this.maybeInfo = this.materializeMaybeProvideInfo() + for (const record of this.scopes.values()) { + record.provideInfo = this.materializeProvideInfo(record.binding) + } + } + + /** Build the static no-session kit and reject duplicate declared names. */ + private materializeMaybeProvideInfo(): SessionMaybeProvideInfo { + const hooks: Record = {} + const props: Record = {} + for (const descriptor of this.providers) { + for (const name of descriptor.hooks ?? []) { + if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) + hooks[name] = undefined + } + for (const name of descriptor.props ?? []) { + if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) + props[name] = undefined + } + } + return { sessionId: undefined, hooks, props } + } + + /** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */ + private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo { + const hooks: Record> = {} + const props: Record = {} + for (const descriptor of this.providers) { + const contribution = descriptor.resolve(binding) + const contributedHooks = contribution.hooks ?? {} + const contributedProps = contribution.props ?? {} + for (const name of Object.keys(contributedHooks)) { + if (!(descriptor.hooks ?? []).includes(name)) { + throw new Error(`sessions.provide: undeclared hook "${name}"`) + } + } + for (const name of Object.keys(contributedProps)) { + if (!(descriptor.props ?? []).includes(name)) { + throw new Error(`sessions.provide: undeclared prop "${name}"`) + } + } + for (const name of descriptor.hooks ?? []) { + const source = contributedHooks[name] + if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`) + if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) + hooks[name] = source + } + for (const name of descriptor.props ?? []) { + if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`) + if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) + props[name] = contributedProps[name] + } + } + return { sessionId: binding.sessionId, hooks, props } + } + /** * Select a session as current. Unknown ids fail loud instead of navigating * nowhere. @@ -205,32 +310,6 @@ export class SessionsService { this.manager.clearSelection() } - /** - * Start or retarget the sole client-local Session intent. - * @param target - resolved real or frontend-only Workspace target. - * @param prompt - optional prompt retained across retargeting. - * @returns the frontend Session object that owns the Intent. - */ - startIntent(target: SessionIntentTarget, prompt = ''): Session { - return this.manager.startIntent(target, prompt) - } - - /** - * Resolve the active frontend Session Intent. - * @returns the active frontend Session object, if one exists. - */ - intent(): Session | undefined { - return this.manager.getIntent() - } - - /** - * Update the retained prompt of the active frontend Session. - * @param text - exact controlled-input value for the current Session Intent. - */ - updateIntent(text: string): void { - this.manager.updateIntent(text) - } - /** * Refresh the real Session baseline, reusing an in-flight pull. * @returns completion of the current or newly started baseline pull. @@ -261,21 +340,26 @@ export class SessionsService { } /** - * Create a session on the host. + * Create a session on the host. Resolution guarantee: by the time the + * promise resolves, the created session is in the list store and + * {@link SessionsService.binding} resolves it — callers (New Session + * draft hand-off) may address the scope synchronously, without waiting a + * notifier flush. The synchronous projection below makes this structural + * rather than an accident of microtask ordering. * @param opts - target workspace or directory and an optional preallocated id. * @returns the new session id. - * @throws {SessionCreateError} with the requested id and, after an attach - * failure, the definitely published id. + * @throws {SessionCreateError} with the requested id. */ async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { const result = await this.manager.create(opts) if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) + this.projectList() return result.value.sessionId } /** - * Resolve a session-scoped context view (use-and-discard). - * @param id - session id. + * Resolve an Agent-scoped context view (use-and-discard). + * @param id - session id (the agent identity — 1:1 same axis). * @returns scoped ctx, or undefined for a session neither listed nor already scoped. */ scope(id: SessionId): Context | undefined { @@ -283,7 +367,7 @@ export class SessionsService { } /** - * Read the session scope tag off a context. Service-method seam: fetch + * Read the Agent scope tag off a context. Service-method seam: fetch * bundles must reach scope resolution through ctx.sessions — a cross-bundle * value import of the standalone helper would inline a second module * instance whose private tag Symbol never matches. @@ -291,7 +375,22 @@ export class SessionsService { * @returns the session id, or undefined on root contexts. */ scopeOf(ctx: Context): SessionId | undefined { - return scopeOf(ctx) + return scopeTagOf(ctx) + } + + /** + * Resolve the business Session behind an Agent-scoped context — the one + * hop every scoped consumer (event listeners, per-session controllers) + * takes from ctx-space into object-space (the client mirror of host + * `agent.session`). Same service-method seam as + * {@link SessionsService.scopeOf}. + * @param ctx - an Agent-scoped context. + * @returns the Session, or undefined when the ctx is untagged or its scope was pruned. + */ + sessionOf(ctx: Context): Session | undefined { + const id = scopeTagOf(ctx) + if (id === undefined) return undefined + return this.scopes.get(id)?.binding.session } /** @@ -305,16 +404,26 @@ export class SessionsService { } /** - * Resolve the render-layer session cell (SessionProvider's feed through - * the renderer host; ctx never enters the render layer). Pure resolution — - * render-safe: SessionProvider calls this during render, so no staging, no - * window side effects (StrictMode double-invokes and concurrent discarded - * passes must stay free). + * Resolve the render-layer standard-props bundle (SessionProvider's feed + * through the renderer host; ctx never enters the render layer). Pure + * resolution — render-safe: SessionProvider calls this during render, so no + * staging, no window side effects (StrictMode double-invokes and concurrent + * discarded passes must stay free). * @param id - session id. - * @returns cell, or undefined for a session neither listed nor already scoped. + * @returns the provide info, or undefined for a session neither listed nor already scoped. */ - cell(id: string): SessionCell | undefined { - return this.resolve(id as SessionId)?.cell + provideInfo(id: string): SessionProvideInfo | undefined { + return this.resolve(id as SessionId)?.provideInfo + } + + /** + * Resolve the current-session-optional standard kit. Unknown or absent ids + * return the static no-session projection rather than removing hook props. + * @param id - current session id, when selected. + * @returns a definite or no-session provide bundle. + */ + maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { + return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo } /** @@ -360,29 +469,42 @@ export class SessionsService { return chain } - /** Lazily mint the scope + binding for a listed (or already-scoped) session. */ + /** + * Lazily mint the scope + binding for an eligible session. Eligibility and + * prune share one predicate (decision 12): listed on the host — a scope is + * born when its session enters the client's view (list mirror row from the + * baseline pull, a create() echo, or the session-added frame) and dies with + * the prune when the row leaves. + */ private resolve(id: SessionId): ScopeRecord | undefined { const existing = this.scopes.get(id) if (existing !== undefined) return existing - // Frozen scopes outlive the list; new scopes are only minted for listed sessions. - if (this.list.getSnapshot().byId[id] === undefined) return undefined - const fiber = this.rootCtx.plugin(sessionScope) - const ctx = fiber.ctx.extend({ [kScope]: id }) + if (!this.eligible(id)) return undefined + const { fiber, ctx } = createScope(this.rootCtx, id) const session = this.manager.get(id) + // The Session owns its scoped dispatch point (host Agent.loopCtx mirror); + // mint and bind are one step so a live scope record implies a bound actx. + session.bindScope(ctx) + const binding: SessionBinding = { sessionId: id, session, ctx } const record: ScopeRecord = { fiber, ctx, - binding: { sessionId: id, session, ctx }, - // Session is the observable; React binds a selector hook at its own seam. - cell: { sessionId: id, session }, + binding, + // Sources are bare observables; React binds selector hooks at its own seam. + provideInfo: this.materializeProvideInfo(binding), } this.scopes.set(id, record) return record } + /** The one aliveness predicate shared by scope mint and prune: host-listed. */ + private eligible(id: SessionId): boolean { + return this.list.getSnapshot().byId[id] !== undefined + } + /** Project the manager's list snapshot into the store (title derivation is display-only). */ private projectList(): void { - const { items, current, intent, phase } = this.manager.getListSnapshot() + const { items, current, phase } = this.manager.getListSnapshot() const ids: SessionId[] = [] const byId: Record = {} for (const entry of items) { @@ -391,6 +513,7 @@ export class SessionsService { id: entry.sessionId, displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, + blank: entry.blank, updatedAt: entry.updatedAt, ...(entry.title !== undefined ? { title: entry.title } : {}), ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), @@ -398,19 +521,22 @@ export class SessionsService { } } const persisted = this.selection.getSnapshot().sessionId - if (intent?.sessionId === current) { + // No current (cleared, or masked gap) wipes the persisted cell — a reload + // stays on empty; the in-memory selection still resurfaces a masked id. + if (current === undefined) { if (persisted !== undefined) this.selection.set({}) - } else if (current !== undefined && byId[current] !== undefined && persisted !== current) { + } else if (byId[current] !== undefined && persisted !== current) { this.selection.set({ sessionId: current }) } - this.list.set({ ids, byId, current, intent, phase }) + this.list.set({ ids, byId, current, phase }) this.pruneScopes(byId) } - /** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */ + /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */ private pruneScopes(byId: Record): void { + void byId for (const [id, record] of this.scopes) { - if (byId[id] !== undefined) continue + if (this.eligible(id)) continue if (id === this.watched) { this.deferredRemovals.add(id) continue @@ -421,12 +547,22 @@ export class SessionsService { } } - /** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */ + /** + * One teardown for the whole per-session axis (decision 12): the scope + * fiber (cascading every actx-registered effect: input shell, slash + * controller, popup, plugin stores, listeners), the session-keyed slot + * stores, and the Session instance itself — the host session log is the + * durable truth, a reopen lazily rebuilds and backfills via open(). + */ private dropScope(id: SessionId, record: ScopeRecord): void { void record.fiber.dispose() + // Release the Session's dispatch point with the scope it belongs to (a + // surviving instance — the live Intent — rebinds when resolve re-mints). + record.binding.session.unbindScope() // Optional lookup: slots and sessions are sibling services with no // declared dependency; a slots-less boot (object-layer tests) skips. this.rootCtx.get('slots')?.pruneStoreScope(id) + this.manager.drop(id) } /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */ @@ -436,8 +572,8 @@ export class SessionsService { * stage move sweeps first, so the set cannot contain the id the stage just * moved to; kept as a guard against future extra sweep call sites. */ if (id === this.watched) continue - // Still absent from the list? (A re-added id cancels the deferred teardown.) - if (this.list.getSnapshot().byId[id] !== undefined) { + // Eligible again? (A re-added id cancels the deferred teardown.) + if (this.eligible(id)) { this.deferredRemovals.delete(id) continue } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 643cf5ac64..1dd0283429 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,18 +1,19 @@ // Sessions remain resident after creation so they continue consuming mux frames off-screen. +import type { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, - SessionId, ToolEventView, WorkspaceId, + SessionId, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ObservableSnapshot } from '../contract/store.ts' import type { - CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt, - PromptError, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, + CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, + PromptError, QueuedMessage, RunningToolCall, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' @@ -23,10 +24,37 @@ import { PartialAccumulator } from './partial.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 -/** Optional frontend Intent and publication observer for a Session object. */ +/** Manager-owned observers of a Session object's local state edges. */ export interface SessionOptions { - intent?: { target: SessionIntentTarget; prompt: string } - onPublished?(session: Session): void + /** + * First ACCEPTED prompt on a blank session (fires at most once, on the + * prompt RPC's success response): the manager mirrors the blank→false flip + * into its list row so the session surfaces without waiting for a host + * frame. Acceptance is the flip point because it proves the user message + * is in the host log; a rejected first prompt keeps the session blank + * (hidden, still reusable by connectWorkspace). + */ + onEngaged?(session: Session): void +} + +/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */ +const QUEUE_PREVIEW_CHARS = 200 + +/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */ +interface QueuedEntry { + row: QueuedMessage + steering: boolean + /** JSON-serialized MessageSource (steering retirement matches by source, the host-mirror precedent). */ + sourceJson: string +} + +/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */ +function queuePreviewOf(content: readonly ContentBlock[]): string { + const flat = content + .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) + .join(' ').replace(/\s+/g, ' ').trim() + const chars = Array.from(flat) + return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat } /** @@ -64,8 +92,16 @@ export class Session implements ObservableSnapshot { private callsCache: { rev: number; value: RunningToolCall[] } | null = null private pendingRev = 0 private pendingCache: { rev: number; value: PendingInteraction[] } | null = null + /** Inbox mirror (session/queued frames + mux-open baseline). Queue frames never hit history, + * so this is stream-only state: reconnect clears it and the fresh baseline re-populates. */ + private queued: QueuedEntry[] = [] + private queueRev = 0 + private queueCache: { rev: number; value: QueuedMessage[] } | null = null private frozenRev = 0 private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null + /** Current whole-list todo/write projection: each tail history response replaces it (an omitted + * field is the authoritative empty list) and every live write overwrites it. */ + private todos: readonly TodoItem[] = [] /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends * copy-on-write the per-parent array so published snapshot references never mutate. */ private codeDispatches = new Map() @@ -78,12 +114,10 @@ export class Session implements ObservableSnapshot { * engaging edge of the phase machine (see ComposerPhase). */ private promptAttempted = false + /** Empty-log mirror (see ConversationSnapshot.blank); monotone false once flipped. */ + private blankBit = false private removed = false private promptError: PromptError | null = null - private intent: SessionIntentSnapshot | null - private pendingPrompt: PendingPrompt | null - private intentGeneration = 0 - private published: boolean private lastAgentError: string | null = null /** Live events buffered during open/resync and stitched by sequence once history lands. */ private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = [] @@ -96,27 +130,46 @@ export class Session implements ObservableSnapshot { private readonly notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() }) + /** + * Agent-scoped cordis context, bound once by SessionsService when it + * mints the scope (the client mirror of the host Agent's loopCtx). The + * Session dispatches its own scoped events through it; undefined means + * unbound (bare object-layer construction) or already pruned — both skip + * dispatch-dependent behavior rather than fail. + */ + private actx: Context | undefined /** - * @param sessionId - stable identity shared by the frontend Intent and Host entity. + * @param sessionId - Host session identity (client sessions are always Host-born). * @param api - shared wire client. - * @param options - optional frontend-only initial state and publication observer. + * @param options - optional manager-owned state observers. */ constructor( readonly sessionId: SessionId, private readonly api: IApiClient, private readonly options: SessionOptions = {}, ) { - this.intent = options.intent === undefined - ? null - : { target: options.intent.target, phase: 'ready' } - this.pendingPrompt = options.intent === undefined - ? null - : { text: options.intent.prompt, phase: 'editing', retry: 'send' } - this.published = options.intent === undefined this.snapshotCache = this.buildSnapshot() } + /** + * Bind the Agent-scoped context minted by SessionsService (single write; + * a second bind is a wiring error and throws). Direction stays one-way at + * the seam: consumers still reach the Session via `sessions.sessionOf`, + * while the Session holds its own dispatch point (host Agent.loopCtx + * mirror). + * @param actx - the agent's scoped context. + */ + bindScope(actx: Context): void { + if (this.actx !== undefined) throw new Error(`session ${this.sessionId} already has a bound scope`) + this.actx = actx + } + + /** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */ + unbindScope(): void { + this.actx = undefined + } + // ---- Operations ---- /** @@ -142,64 +195,22 @@ export class Session implements ObservableSnapshot { if (!result.ok) { this.promptError = { op: 'send', error: result.error } this.notifier.markDirty() + return result + } + // Blank flips on ACCEPTANCE, not attempt: an accepted prompt has logged + // its user/message on the host (events.length > 0 is fact, not + // optimism), while a rejected first prompt must keep the session blank + // — the client-side blank mirror only ever lowers, so flipping early on + // a failure would surface the session forever and strip its + // connectWorkspace reuse eligibility against the host's authority. + if (this.blankBit) { + this.blankBit = false + this.options.onEngaged?.(this) + this.notifier.markDirty() } return result } - /** - * Update this Session's retained prompt while it remains editable. - * @param text - exact controlled value of this Session's retained prompt. - */ - updatePendingPrompt(text: string): void { - const pending = this.pendingPrompt - if (pending === null || pending.phase === 'sending') return - this.pendingPrompt = { ...pending, text } - this.notifier.notifyNow() - } - - /** - * Connect this frontend Session to a real Workspace and flush its retained prompt. - * @param workspaceId - real Workspace target. - */ - connect(workspaceId: WorkspaceId): void { - const intent = this.intent - const pending = this.pendingPrompt - if (intent === null || intent.phase === 'connecting' || pending === null || pending.text.trim() === '') return - const connecting: SessionIntentSnapshot = { - target: { kind: 'workspace', workspaceId }, - phase: 'connecting', - } - const queued: PendingPrompt = { - ...pending, - phase: 'sending', - retry: 'connect', - workspaceId, - } - delete queued.error - this.intent = connecting - this.pendingPrompt = queued - this.notifier.notifyNow() - void this.flushPendingPrompt() - } - - /** Stop a superseded frontend Intent from automatically sending after publication. */ - abandonIntent(): void { - if (this.intent === null) return - this.intentGeneration += 1 - } - - /** Retry this Session's retained prompt from its failed prerequisite. */ - retryPendingPrompt(): void { - const pending = this.pendingPrompt - if (pending === null || pending.phase === 'sending' || pending.text.trim() === '') return - const sending: PendingPrompt = { ...pending, phase: 'sending' } - delete sending.error - this.pendingPrompt = sending - this.promptError = null - this.notifier.markDirty() - void this.flushPendingPrompt() - } - /** * Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot). * @returns the cancel result. @@ -272,6 +283,11 @@ export class Session implements ObservableSnapshot { * in-flight open first — its history request rode the dead connection and must not settle * the fresh generation into 'error' (audit S4). */ async resync(): Promise { + // The queue mirror is NOT cleared here: onConnected (which drives resync) + // races the mux frames — the fresh generation's baseline may have landed + // already, and the host never resends it. The mirror re-baselines on the + // session/subscribed frame instead (same stream as the queue snapshot + // that follows it, so ordering is guaranteed). if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) this.openGeneration++ this.openPromise = null @@ -320,12 +336,35 @@ export class Session implements ObservableSnapshot { handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void { switch (frame.type) { case 'session/event': { + this.retireQueued(frame.event) this.acceptLiveEvent(frame.event, frame.view) return } + case 'session/queued': { + // Row key: the enqueueing prompt's rpcId when it rode this wire (the + // provisional-echo reconciliation key); otherwise the frame envelope id. + const key = 'rpcId' in frame.source ? String(frame.source.rpcId) : `f:${rpcId}` + this.queued.push({ + row: { key, preview: queuePreviewOf(frame.content) }, + steering: frame.steering, + sourceJson: JSON.stringify(frame.source), + }) + this.queueRev++ + this.notifier.markDirty() + return + } case 'session/subscribed': { this.subscribedLastSeq = frame.lastSeq - return // pure baseline bookkeeping, no visible change + // New mux-generation baseline: the host pushes this session's queue + // snapshot AFTER the subscribed frame on the same stream, so the + // stale mirror clears here — race-free against onConnected/resync + // timing (clearing there could wipe a baseline that already landed). + if (this.queued.length > 0) { + this.queued = [] + this.queueRev++ + this.notifier.markDirty() + } + return } case 'approval/requested': { const { type: _type, sessionId: _sid, ...payload } = frame @@ -362,14 +401,38 @@ export class Session implements ObservableSnapshot { * @param running - the new running state. */ handleRunning(running: boolean): void { + // Leave-running sweep (host queuedMirror precedent): discard paths (cancel, + // terminal steering drop) have no per-entry frame, so ANY not-running signal + // with a nonempty mirror clears it — checked before the equality return so a + // stale replay on an already-idle session still sweeps. + if (!running && this.queued.length > 0) { + this.queued = [] + this.queueRev++ + this.notifier.markDirty() + } + // Turn-start conversion: a blank session never runs, so the first + // running:true proves another端's first message landed (设计稿 2.2). + if (running && this.blankBit) { + this.blankBit = false + this.notifier.markDirty() + } if (this.running === running) return this.running = running this.notifier.markDirty() } - /** Mark that Host publication is known without resolving an uncertain local create response. */ - handlePublished(): void { - this.markPublished() + /** + * Blank-bit relay from the authoritative summary source (list baseline and + * the session-added frame). Monotone: once any signal (local first send, + * running flip, an earlier summary) cleared it, a stale true never + * re-blanks. + * @param blank - the summary's derived empty-log bit. + */ + handleBlank(blank: boolean): void { + if (blank === this.blankBit) return + if (blank && (this.promptAttempted || this.running)) return + this.blankBit = blank + this.notifier.markDirty() } /** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */ @@ -405,112 +468,6 @@ export class Session implements ObservableSnapshot { this.pendingRev++ } - /** Advance the retained prompt through Session attachment and submission. */ - private async flushPendingPrompt(): Promise { - const pending = this.pendingPrompt - if (pending?.phase === 'sending') { - const ready = pending.retry === 'connect' - ? await this.attachPendingPrompt(pending) - : pending - if (ready !== null) await this.sendPendingPrompt(ready) - } - } - - /** Complete the Host Session prerequisite and return the prompt's send step. */ - private async attachPendingPrompt(pending: PendingPrompt): Promise { - const workspaceId = pending.workspaceId - if (workspaceId === undefined) throw new Error('a Session attachment requires a Workspace id') - const originIntent = this.intent - const originGeneration = this.intentGeneration - let result: RpcResult<{ sessionId: SessionId }> - try { - result = (await this.api.sessions.create({ sessionId: this.sessionId, workspaceId })).result - } catch (error) { - result = transportError(error) - } - let ready: PendingPrompt | null = null - if (result.ok) { - ready = this.completePendingAttachment(pending, originIntent, originGeneration) - } else { - this.failPendingAttachment(pending, originIntent, originGeneration, result.error) - } - this.notifier.markDirty() - return ready - } - - /** Move a published Session to the send step unless its page intent was superseded. */ - private completePendingAttachment( - pending: PendingPrompt, - originIntent: SessionIntentSnapshot | null, - originGeneration: number, - ): PendingPrompt | null { - this.markPublished() - this.intent = null - this.promptAttempted = true - const superseded = originIntent !== null && originGeneration !== this.intentGeneration - const next: PendingPrompt = { - ...pending, - phase: superseded ? 'failed' : 'sending', - retry: 'send', - ...(superseded ? { error: 'Message was not sent because you navigated away.' } : {}), - } - if (!superseded) delete next.error - this.pendingPrompt = next - return superseded ? null : next - } - - /** Retain the prompt at the failed attachment step that owns the retry. */ - private failPendingAttachment( - pending: PendingPrompt, - originIntent: SessionIntentSnapshot | null, - originGeneration: number, - error: RpcError, - ): void { - const partiallyPublished = error.code === 'workspace-attach-failed' - if (partiallyPublished) { - this.markPublished() - this.intent = null - this.promptAttempted = true - } - const activeIntent = !partiallyPublished - && originIntent !== null - && originGeneration === this.intentGeneration - && this.intent === originIntent - if (activeIntent) { - this.intent = { - target: originIntent.target, - phase: 'ready', - error: { step: 'session', message: rpcErrorMessage(error) }, - } - this.pendingPrompt = { ...pending, phase: 'editing' } - } - if (!activeIntent && (partiallyPublished || originIntent === null) && this.pendingPrompt === pending) { - this.pendingPrompt = { ...pending, phase: 'failed', error: rpcErrorMessage(error) } - } - } - - /** Submit the retained prompt and keep it only when Host rejects the send. */ - private async sendPendingPrompt(pending: PendingPrompt): Promise { - const result = await this.prompt([{ type: 'text', text: pending.text.trim() }], 'queue') - if (this.pendingPrompt === pending) { - this.pendingPrompt = result.ok - ? null - : { - ...pending, - retry: 'send', - phase: 'failed', - error: rpcErrorMessage(result.error), - } - this.notifier.markDirty() - } - } - - private markPublished(): void { - if (this.published) return - this.published = true - this.options.onPublished?.(this) - } - /** @param generation - openGeneration at launch; every await re-checks it and a stale pass * drops all writes (resync superseded this open — its outcome belongs to a dead connection). */ private async doOpen(generation: number): Promise { @@ -525,13 +482,13 @@ export class Session implements ObservableSnapshot { this.openError = result.error return } - this.installWindow(result.value.events, result.value.hasMore) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos) // Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more. const tailSeq = this.windowTailSeq() if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) { result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result if (generation !== this.openGeneration) return - if (result.ok) this.installWindow(result.value.events, result.value.hasMore) + if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos) } this.openState = 'open' } catch (error) { @@ -549,11 +506,19 @@ export class Session implements ObservableSnapshot { * Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here * (doOpen flips it after install), so recursing would push every buffered event straight * back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */ - private installWindow(entries: HistoryEntry[], hasMore: boolean): void { + private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore + // Session-level projection from the tail page (full-log latest todo/write, + // independent of the window); an in-window write below re-derives the same + // value, and later live events keep overwriting it. Every caller here is a + // tail request (no beforeSeq), 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, not a missing carrier. Assigning + // it clears a plan the log never kept (a write lost to a host crash). + this.todos = todos ?? [] this.foldAdapter.reset(this.events, this.baseSeq, this.views) this.rebuildDerivedFromWindow() const buffered = this.liveBuffer @@ -604,7 +569,7 @@ export class Session implements ObservableSnapshot { const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES }) // Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself. if (result.ok && generation === this.openGeneration && this.openState === 'open') { - this.installWindow(result.value.events, result.value.hasMore) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos) } } catch (error) { console.error('[web-runtime] gap repair failed:', error) @@ -613,6 +578,27 @@ export class Session implements ObservableSnapshot { } } + /** Consumption-event retirement, mirroring the host queuedMirror rules: a message-triggered + * turn/start claims the oldest non-steering entry; a steering/message drains the oldest + * steering entry with the same source (loop-authored steering matches nothing and drops none). */ + private retireQueued(event: SessionEvent): void { + if (this.queued.length === 0) return + let index = -1 + if (event.type === 'turn/start') { + if (event.data.trigger.kind !== 'message') return + index = this.queued.findIndex(entry => !entry.steering) + } else if (event.type === 'steering/message') { + const source = JSON.stringify(event.data.source) + index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source) + } else { + return + } + if (index < 0) return + this.queued.splice(index, 1) + this.queueRev++ + this.notifier.markDirty() + } + /** Per-event side effects (right column of the §A.9 dispatch table): * chunk accumulation / partial clear on finalize / openCalls add-remove. */ private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void { @@ -703,6 +689,10 @@ export class Session implements ObservableSnapshot { if (this.openCalls.delete(String(event.data.callId))) this.callsRev++ return } + case 'todo/write': { + this.todos = event.data.todos + return + } case 'turn/end': { // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. @@ -747,7 +737,10 @@ export class Session implements ObservableSnapshot { /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes the live freeze and the history replay converge on the - * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ + * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). + * todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log + * projection, not derivable from an arbitrary window). The window always extends to the log + * tail, so an in-window todo/write can only overwrite it with the same latest value. */ private rebuildDerivedFromWindow(): void { this.partial = null this.openCalls.clear() @@ -791,6 +784,9 @@ export class Session implements ObservableSnapshot { if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) { this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) } } + if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { + this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) } + } const partial = this.partial?.toPartial() ?? null return { sessionId: this.sessionId, @@ -800,6 +796,7 @@ export class Session implements ObservableSnapshot { runningCalls: this.callsCache.value, pending: this.pendingCache.value, codeDispatches: this.dispatchesCache.value, + queue: this.queueCache.value, running: this.running, composerPhase: derivePhase( nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0, @@ -811,17 +808,13 @@ export class Session implements ObservableSnapshot { hasMore: this.hasMore, loadingOlder: this.loadingOlder, promptError: this.promptError, - intent: this.intent, - pendingPrompt: this.pendingPrompt, + blank: this.blankBit, lastAgentError: this.lastAgentError, + todos: this.todos, } } } -function rpcErrorMessage(error: RpcError): string { - return `${error.code}: ${error.message}` -} - /** * The composerPhase judgment — the single site that knows the predicate * (consumers switch on the result, never re-derive). Monotone per session diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 2a19dcef56..74af31502a 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -235,7 +235,7 @@ export class SlotsService extends Service { } } - /** Build once after both object-layer services mount; session cells still resolve lazily. */ + /** Build once after both object-layer services mount; per-session provide bundles still resolve lazily. */ private hostFace(): SlotRendererHost { if (this._host !== undefined) return this._host const sessions = this.ctx.get('sessions') @@ -264,7 +264,8 @@ export class SlotsService extends Service { sessions: { list: sessions.list, current, - cell: id => sessions.cell(id), + provideInfo: id => sessions.provideInfo(id), + maybeProvideInfo: id => sessions.maybeProvideInfo(id), }, workspaces: { list: workspaces.list }, } @@ -275,13 +276,13 @@ export class SlotsService extends Service { private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike { const record = this._stores.get(handle) if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)') - const key = record.scope === 'session' ? sessionId : ROOT_INSTANCE_KEY - if (key === undefined) throw new Error('session-scoped store resolution requires a session id') + const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : sessionId + if (key === undefined) throw new Error(`${record.scope} store resolution requires a session id`) let instance = record.instances.get(key) if (instance === undefined) { // Session instances get the scope key (the engine suffixes the persist // key per session); root instances stay keyless. - instance = record.scope === 'session' ? handle.create(key) : handle.create() + instance = record.scope === 'root' ? handle.create() : handle.create(key) record.instances.set(key, instance) } return instance diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index c512694816..ce4198cd01 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -6,11 +6,7 @@ import type { import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { Notifier } from '../sessions/notifier.ts' -import { - Workspace, type WorkspaceCreateInput, type WorkspaceIntentSnapshot, -} from './workspace.ts' - -export type { WorkspaceIntentSnapshot } from './workspace.ts' +import { Workspace, type WorkspaceCreateInput } from './workspace.ts' /** Monotone workspace-list arrival lifecycle. */ export type WorkspaceListPhase = 'pending' | 'ready' @@ -18,24 +14,34 @@ export type WorkspaceListPhase = 'pending' | 'ready' /** Immutable workspace-list snapshot. */ export interface WorkspaceListSnapshot { items: readonly WorkspaceView[] - /** The sole page-local Workspace intent; never persisted or sent over the Host stream. */ - intent: WorkspaceIntentSnapshot | undefined state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null } +type WorkspaceDelta = + | { type: 'upsert'; workspace: WorkspaceView } + | { type: 'remove'; workspaceId: WorkspaceId } + /** Workspace object cluster driven by one list baseline and changed-frame upserts. */ export class WorkspaceManager { private items: Workspace[] = [] - private intent: Workspace | undefined private itemViewsSource: readonly Workspace[] | null = null private itemViewsCache: readonly WorkspaceView[] = [] private state: WorkspaceListSnapshot['state'] = 'idle' private phase: WorkspaceListPhase = 'pending' private error: RpcError | null = null private inflight: Promise | null = null - private refreshFrames: WorkspaceView[] | null = null + private refreshFrames: WorkspaceDelta[] | null = null + /** + * Ids this process has seen removed, kept for the connection's lifetime so + * a late changed frame or a stale baseline row cannot resurrect a deleted + * row. Correctness rests on Host ids never being reused (the registry mints + * a fresh `randomUUID` per record, including when the same directory is + * registered again) — a path-derived id scheme would turn these entries + * into permanent blindfolds and must clear them instead. + */ + private readonly removedIds = new Set() private snapshotCache: WorkspaceListSnapshot private readonly notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() @@ -46,44 +52,6 @@ export class WorkspaceManager { this.snapshotCache = this.buildSnapshot() } - /** - * Replace the current client-local Workspace intent object. - * @param name - directory/display name used if the intent is materialized. - * @returns the new intent snapshot. - */ - startIntent(name = 'workspace'): WorkspaceIntentSnapshot { - this.intent = new Workspace(this.api, { name }) - this.notifier.notifyNow() - return this.intent.getSnapshot().intent as WorkspaceIntentSnapshot - } - - /** Discard the current client-local Workspace intent. */ - discardIntent(): void { - if (this.intent === undefined) return - this.intent = undefined - this.notifier.notifyNow() - } - - /** - * Materialize the current Workspace intent through the ordinary Host create seam. - * A superseded intent is never cleared by an older completion. - * @returns the Host create result, or undefined when no intent exists. - */ - async materializeIntent(): Promise | undefined> { - const intent = this.intent - if (intent?.getSnapshot().intent?.phase !== 'ready') return undefined - const completion = intent.materialize() - if (completion === undefined) return undefined - this.notifier.notifyNow() - const result = await completion - if (result.ok) { - this.upsert(result.value.workspace, intent) - if (this.intent === intent) this.intent = undefined - } - this.notifier.markDirty() - return result - } - /** * Refresh from workspace.list. The first successful response establishes * Host order; later responses update membership and values without moving @@ -96,7 +64,7 @@ export class WorkspaceManager { this.state = 'loading' this.error = null const established = this.itemViews() - const frames: WorkspaceView[] = [] + const frames: WorkspaceDelta[] = [] this.refreshFrames = frames this.notifier.markDirty() this.inflight = (async () => { @@ -106,7 +74,8 @@ export class WorkspaceManager { let items = this.phase === 'pending' ? result.value.items : mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId) - for (const workspace of frames) items = upsertWorkspace(items, workspace) + items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) + for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) this.state = 'idle' this.phase = 'ready' @@ -156,6 +125,18 @@ export class WorkspaceManager { return result } + /** + * Delete a Workspace registration and remove its local projection from the + * unary response without waiting for the Host frame. + * @param workspaceId - target workspace. + * @returns the wire result. + */ + async delete(workspaceId: WorkspaceId): Promise> { + const { result } = await this.api.workspace.delete({ workspaceId }) + if (result.ok) this.remove(workspaceId, true) + return result + } + /** * Move a session within its Workspace's manual order, then publish the * returned snapshot without waiting for the changed frame. @@ -184,6 +165,7 @@ export class WorkspaceManager { */ handleHostEnvelope(envelope: RpcRequest): void { if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) + else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) } /** Re-pull the baseline after each connection generation. */ @@ -212,7 +194,6 @@ export class WorkspaceManager { private buildSnapshot(): WorkspaceListSnapshot { return { items: this.itemViews(), - intent: this.intent?.getSnapshot().intent, state: this.state, phase: this.phase, error: this.error, @@ -221,7 +202,8 @@ export class WorkspaceManager { /** Upsert one Host view, optionally retaining the local object that materialized it. */ private upsert(view: WorkspaceView, identity?: Workspace): void { - this.refreshFrames?.push(view) + if (this.removedIds.has(view.workspaceId)) return + this.refreshFrames?.push({ type: 'upsert', workspace: view }) const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) // Mutation responses and changed frames race (two carriers, no ordering): // reject a snapshot strictly older than the installed projection so a @@ -241,6 +223,24 @@ export class WorkspaceManager { this.notifier.markDirty() } + /** Remove one id idempotently and retain a tombstone against late echoes. */ + private remove(workspaceId: WorkspaceId, direct = false): void { + this.refreshFrames?.push({ type: 'remove', workspaceId }) + this.removedIds.add(workspaceId) + const items = this.items.filter(item => + item.getSnapshot().view?.workspaceId !== workspaceId) + if (items.length === this.items.length) { + // The Host frame may have removed the row first but left its batched + // notification pending. A successful unary echo still flushes that + // committed state before the user action resolves. + if (direct) this.notifier.notifyNow() + return + } + this.items = items + if (direct) this.notifier.notifyNow() + else this.notifier.markDirty() + } + private installViews(views: readonly WorkspaceView[]): void { const existing = new Map( this.items.flatMap((workspace) => { @@ -280,3 +280,10 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi ? [workspace, ...items] : items.map((item, position) => position === index ? workspace : item) } + +/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */ +function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { + return delta.type === 'upsert' + ? upsertWorkspace(items, delta.workspace) + : items.filter(workspace => workspace.workspaceId !== delta.workspaceId) +} diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 9768a3fac2..4cb26aa220 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -7,13 +7,11 @@ import type { import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionsService } from '../sessions/service.ts' -import { WorkspaceManager, type WorkspaceIntentSnapshot, type WorkspaceListPhase } from './manager.ts' +import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts' /** Workspace list plus the two-baseline readiness and default-target projection. */ export interface WorkspaceListState { items: readonly WorkspaceView[] - /** Sole client-local Workspace projection; its state remains owned by Workspace. */ - intent: WorkspaceIntentSnapshot | undefined state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -29,64 +27,128 @@ export class WorkspacesService { readonly list: SnapshotStore /** Workspace baseline and frame owner. */ private readonly manager: WorkspaceManager - private initialSessionResolved = false - private composingIntent = false + /** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */ + private readonly connecting = new Map>() + /** Guards the runtime-owned one-shot initial-selection subscription. */ + private initialSelectionStarted = false /** * @param ctx - client root context. * @param api - shared wire client. - * @param sessions - lower-level Session service used for recency and cross-domain intent orchestration. + * @param sessions - lower-level Session service used for recency and blank-session reuse. */ constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) { this.manager = new WorkspaceManager(api) this.list = createSnapshotStore({ - items: [], intent: undefined, state: 'idle', phase: 'pending', error: null, + items: [], state: 'idle', phase: 'pending', error: null, baselinesReady: false, recentWorkspaceId: undefined, }) - this.manager.subscribe(() => { if (!this.composingIntent) this.project() }) - this.sessions.list.subscribe(() => { if (!this.composingIntent) this.project() }) + this.manager.subscribe(() => { this.project() }) + this.sessions.list.subscribe(() => { this.project() }) ctx.reflect.provide('workspaces', this, undefined) } /** - * Start the sole Session intent, resolving the default Workspace here. - * @param workspaceId - optional explicit real Workspace target. - * @param prompt - optional prompt retained while retargeting. + * Resolve the session a New Session flow lands in once this Workspace is + * chosen: reuse the workspace's existing blank session when one is in the + * list mirror, else create a fresh one on the host (`session.create` births + * the full Session+Agent — the client holds no intermediate state). The + * caller owns navigation: take the returned id to `sessions.open`. + * Resolution guarantee (both arms): the returned id is already in the list + * store and `sessions.binding(id)` resolves synchronously — draft hand-off + * may write the new scope's machine before opening. + * @param workspaceId - chosen Workspace (must be in the workspace list). + * @returns the reused or newly created session id. */ - startSession(workspaceId?: WorkspaceId, prompt = ''): void { - const snapshot = this.list.getSnapshot() - const resolved = workspaceId ?? snapshot.recentWorkspaceId ?? snapshot.items[0]?.workspaceId - this.composingIntent = true - try { - if (resolved === undefined) { - this.manager.startIntent() - this.sessions.startIntent({ kind: 'workspace-intent' }, prompt) - } else { - this.manager.discardIntent() - this.sessions.startIntent({ kind: 'workspace', workspaceId: resolved }, prompt) + async connectWorkspace(workspaceId: WorkspaceId): Promise { + const workspace = this.list.getSnapshot().items.find(item => item.workspaceId === workspaceId) + if (workspace === undefined) throw new Error(`workspaces.connectWorkspace: unknown workspace ${workspaceId}`) + // Coalesce concurrent connects: a create's summary lands without cwd + // until the host frame arrives, so a second call inside that window + // would miss the reuse scan and mint another hidden blank session. + const inflight = this.connecting.get(workspaceId) + if (inflight !== undefined) return inflight + // Reuse: blank && same canonical cwd (workspace.path is the host realpath + // canon; summary cwd is the session header passthrough of the same canon). + const sessions = this.sessions.list.getSnapshot() + for (const id of sessions.ids) { + const summary = sessions.byId[id] + if (summary !== undefined && summary.blank && summary.cwd === workspace.path) return summary.id + } + const attempt = this.sessions.create({ workspaceId }) + .finally(() => { this.connecting.delete(workspaceId) }) + this.connecting.set(workspaceId, attempt) + return attempt + } + + /** + * Follow the first complete Workspace/Session baseline and select a default + * session exactly once. A restored current session wins; otherwise the most + * recent Workspace is connected (reusing or creating its blank session). + * Later explicit clears stay cleared instead of retriggering this startup + * policy. A failed connect may retry on the next baseline projection. + * @returns disposer for the baseline subscription; late work cannot navigate after disposal. + */ + startInitialSelection(): () => void { + if (this.initialSelectionStarted) { + throw new Error('workspaces.startInitialSelection: already started') + } + this.initialSelectionStarted = true + let state: 'waiting' | 'connecting' | 'done' = 'waiting' + let disposed = false + const reconcile = (): void => { + if (disposed || state !== 'waiting') return + const workspace = this.list.getSnapshot() + if (!workspace.baselinesReady) return + const current = this.sessions.list.getSnapshot().current + const target = workspace.recentWorkspaceId + if (current !== undefined || target === undefined) { + state = 'done' + return } - } finally { - this.composingIntent = false - this.project() + state = 'connecting' + void this.connectWorkspace(target).then( + (sessionId) => { + if (disposed) return + if (this.sessions.list.getSnapshot().current === undefined) { + this.sessions.open(sessionId) + } + state = 'done' + }, + (reason: unknown) => { + if (disposed) return + state = 'waiting' + console.warn('initial workspace selection failed:', reason) + }, + ) + } + const unsubscribe = this.list.subscribe(reconcile) + reconcile() + return () => { + disposed = true + unsubscribe() } } - /** Connect the current frontend Workspace and Session, then flush the Session-owned prompt. */ - sendSession(): void { - const session = this.sessions.intent() - const target = session?.getSnapshot().intent?.target - if (session === undefined || target === undefined) return - if (target.kind === 'workspace') { - session.connect(target.workspaceId) + /** + * The shared New Session action behind the shell entry points (sidebar + * button, workspace browser): resolve the target Workspace — explicit wins, + * else the recent-Workspace projection — connect its blank session and + * navigate there; with no Workspace at all, clear the selection into the + * New Session view state. Connect failures are non-fatal (console + * diagnostics; the current view stays usable). + * @param workspaceId - explicit target Workspace for scoped actions. + */ + startSession(workspaceId?: WorkspaceId): void { + const target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId + if (target === undefined) { + this.sessions.clear() return } - if (session.getSnapshot().pendingPrompt?.text.trim() === '') return - void this.manager.materializeIntent().then((result) => { - if (this.sessions.intent() !== session) return - if (result?.ok) { - session.connect(result.value.workspace.workspaceId) - } - }) + void this.connectWorkspace(target).then( + (sessionId) => { this.sessions.open(sessionId) }, + (reason: unknown) => { console.warn('new session failed:', reason) }, + ) } /** @@ -112,6 +174,16 @@ export class WorkspacesService { return result.value.workspace } + /** + * Delete one Workspace registration. Sessions, session logs, and the + * directory remain Host-owned outside this operation. + * @param workspaceId - target workspace. + */ + async delete(workspaceId: WorkspaceId): Promise { + const result = await this.manager.delete(workspaceId) + if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) + } + /** * Move a session within its Workspace's manual order (DOM-insertBefore-like). * @param workspaceId - owning workspace. @@ -153,20 +225,15 @@ export class WorkspacesService { private project(): void { const workspace = this.manager.getSnapshot() const sessions = this.sessions.list.getSnapshot() - if (workspace.intent !== undefined && sessions.intent?.target.kind !== 'workspace-intent') { - this.manager.discardIntent() - return - } const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready' this.list.set({ - ...workspace, + items: workspace.items, + state: workspace.state, + phase: workspace.phase, + error: workspace.error, baselinesReady, recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined, }) - if (!this.initialSessionResolved && baselinesReady) { - this.initialSessionResolved = true - if (sessions.current === undefined && sessions.intent === undefined) this.startSession() - } } } diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 14fede564d..d5b29f10a9 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -8,7 +8,9 @@ import { describe, expect, it } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import * as RuntimeClient from '../src/client/index.ts' -import { FakeApiClient } from './fake-api.ts' +import type { SessionsService } from '../src/client/sessions/service.ts' +import type { WorkspacesService } from '../src/client/workspaces/service.ts' +import { FakeApiClient, ok } from './fake-api.ts' interface Bench { ctx: Context @@ -33,6 +35,10 @@ async function mount(): Promise { return bench } +async function flushMicrotasks(): Promise { + for (let i = 0; i < 12; i++) await Promise.resolve() +} + describe('runtime client apply', () => { it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => { const bench = await mount() @@ -50,7 +56,7 @@ describe('runtime client apply', () => { // Frame sinks reach the object layer: a host session-added lands in the list store. bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, - payload: { type: 'host/session-added', sessionId: 's-new' } as never, + payload: { type: 'host/session-added', blank: true, sessionId: 's-new' } as never, }) await Promise.resolve() expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new') @@ -71,6 +77,31 @@ describe('runtime client apply', () => { bench.sinks?.onConnected?.() }) + it('selects the recent Workspace once when the first baselines have no current session', async () => { + const bench = await mount() + bench.api.onWorkspaceList = () => Promise.resolve(ok({ + items: [{ + workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }] as never[], + })) + bench.api.onList = () => Promise.resolve(ok({ items: [] })) + + bench.sinks?.onConnected?.() + await flushMicrotasks() + + const sessions = bench.ctx.get('sessions') as SessionsService + const workspaces = bench.ctx.get('workspaces') as WorkspacesService + expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }]) + expect(sessions.list.getSnapshot().current).toBe('fk-new') + + sessions.clear() + await workspaces.refresh() + await flushMicrotasks() + expect(sessions.list.getSnapshot().current).toBeUndefined() + expect(bench.api.callsOf('session.create')).toHaveLength(1) + }) + it('stops the stream loop when the plugin fiber unloads', async () => { const bench = await mount() const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client')) diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 7fb150bb20..ada8550136 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -40,6 +40,8 @@ export const ev = { at(seq, { type: 'step/end', data: { turn, step } }), turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), + todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent => + at(seq, { type: 'todo/write', data: { todos } }), } /** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */ diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index a9fbda4907..d906ff92a8 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -2,7 +2,8 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, + ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, + RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -61,7 +62,7 @@ export class FakeApiClient implements IApiClient { onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) @@ -95,6 +96,9 @@ export class FakeApiClient implements IApiClient { onWorkspaceRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + onWorkspaceDelete: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ deleted: true })) + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) @@ -102,10 +106,30 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), } + // Payloads stay `unknown` (lint-lane note above); response rows are the real + // wire shapes so cases can program requires-bearing catalogs and dual-address + // skill lists without casts. + onCommandList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ commands: [] })) + onCommandExecute: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ matched: false })) + onSkillList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ skills: [] })) + + readonly commands: IApiClient['commands'] = { + list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), + execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), + } + + readonly skills: IApiClient['skills'] = { + list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/runtime/tests/lineage.spec.ts b/packages/client/runtime/tests/lineage.spec.ts index 1963f9c261..c616c19462 100644 --- a/packages/client/runtime/tests/lineage.spec.ts +++ b/packages/client/runtime/tests/lineage.spec.ts @@ -8,7 +8,7 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connecti import { flattenLineage } from '../src/client/sessions/lineage.ts' const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({ - sessionId: id as SessionId, updatedAt, running: false, + sessionId: id as SessionId, updatedAt, running: false, blank: false, ...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}), }) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index c532454224..ee76d885ab 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -12,8 +12,10 @@ import { entries, plainTurn } from './event-script.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId -function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; parentSessionId: SessionId }> = {}) { - return { sessionId, updatedAt: 100, running: false, ...over } +type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }> + +function summary(sessionId: SessionId, over: SummaryOver = {}) { + return { sessionId, updatedAt: 100, running: false, blank: false, ...over } } describe('instances', () => { @@ -81,7 +83,7 @@ describe('list lifecycle', () => { const hydration = manager.refreshList() manager.handleHostEnvelope({ rpcId: 'during-first' as never, - payload: { type: 'host/session-added', sessionId: S2 }, + payload: { type: 'host/session-added', blank: true, sessionId: S2 }, }) first.resolve(ok({ items: [summary(S1)] as never[] })) await hydration @@ -157,7 +159,7 @@ describe('list lifecycle', () => { expect(titled.items[1]?.title).toBeUndefined() manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) - manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) @@ -196,8 +198,8 @@ describe('host frame routing', () => { it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) - manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S1 } }) // dup: ignored + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored expect(manager.getListSnapshot().items).toHaveLength(1) const session = manager.get(S1) @@ -273,14 +275,14 @@ describe('remaining branches', () => { manager.handleHostEnvelope({ rpcId: 'published-later' as never, - payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' }, + payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' }, }) expect(manager.getListSnapshot().items).toEqual([ expect.objectContaining({ sessionId: S1, cwd: '/w/one' }), ]) manager.handleHostEnvelope({ rpcId: 'duplicate-frame' as never, - payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' }, + payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' }, }) expect(manager.getListSnapshot().items).toHaveLength(1) }) @@ -295,7 +297,7 @@ describe('remaining branches', () => { expect(notified).toBeGreaterThan(0) const seen = notified unsubscribe() - manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) await new Promise(resolve => setTimeout(resolve, 0)) expect(notified).toBe(seen) }) @@ -334,8 +336,8 @@ describe('remaining branches', () => { it('carries parentSessionId from host/session-added into the lineage row', () => { const api = new FakeApiClient() const manager = new SessionManager(api) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) - manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2, parentSessionId: S1 } }) const items = manager.getListSnapshot().items expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 }) }) diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts new file mode 100644 index 0000000000..360f7c1a9d --- /dev/null +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -0,0 +1,193 @@ +/** + * Queue mirror semantics (web input-triggers queue cut 1): session/queued + * intake, host-rule retirement (message turn/start claims oldest non-steering; + * steering/message drains by source), leave-running sweep, reconnect reset, + * pre-instantiation buffering, and snapshot reference stability. + */ +import { describe, expect, it } from 'vitest' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import { Session } from '../src/client/sessions/session.ts' +import { SessionManager } from '../src/client/sessions/manager.ts' +import { FakeApiClient } from './fake-api.ts' +import { ev } from './event-script.ts' + +const SID = 'fk-q1' as SessionId +const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }] +const rid = (id: string): RpcId => id as RpcId + +/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */ +function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame { + return { + type: 'session/queued', sessionId: SID, content: text(body), + source: { kind: 'user', rpcId: rid(rpcId) } as never, steering, + } +} + +function makeSession(): Session { + return new Session(SID, new FakeApiClient()) +} + +describe('queue intake', () => { + it('lands a queued frame as a row keyed by the source rpcId with a flat preview', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('env-1'), queuedFrame('第一条 排队\n消息', 'p-1')) + const queue = session.getSnapshot().queue + expect(queue).toEqual([{ key: 'p-1', preview: '第一条 排队 消息' }]) + }) + + it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('env-2'), { + type: 'session/queued', sessionId: SID, + content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], + source: { kind: 'plugin', plugin: 'loop' }, steering: false, + }) + expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }]) + }) + + it('caps the preview at 200 code points with an ellipsis', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap')) + const preview = session.getSnapshot().queue[0]?.preview ?? '' + expect(Array.from(preview)).toHaveLength(201) // 200 + … + expect(preview.endsWith('…')).toBe(true) + }) + + it('keeps the queue array reference stable across unrelated snapshot swaps', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('env-4'), queuedFrame('稳定', 'p-s')) + const before = session.getSnapshot().queue + session.handleAgentError('unrelated') // dirties the snapshot without touching the queue + expect(session.getSnapshot().queue).toBe(before) + }) +}) + +describe('queue retirement (host queuedMirror rules)', () => { + it('a message-triggered turn/start claims the oldest non-steering row', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1')) + session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2')) + session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) }) + expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2']) + }) + + it('an injection-triggered turn/start claims nothing', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1')) + const injection = { + ...ev.turnStart(0, 0), + data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } }, + } as never + session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection }) + expect(session.getSnapshot().queue).toHaveLength(1) + }) + + it('steering/message drains the source-matched steering row only', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) + session.handleMuxEnvelope(rid('e2'), queuedFrame('插话', 'p-2', true)) + // Loop-authored steering (different source) must not consume the user entry. + const foreignSteering = { + seq: 0, time: 1, + type: 'steering/message', surfaceOp: 'append', + data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } }, + } as never + session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: foreignSteering }) + expect(session.getSnapshot().queue).toHaveLength(2) + const matchedSteering = { + seq: 1, time: 2, + type: 'steering/message', surfaceOp: 'append', + data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } }, + } as never + session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: matchedSteering }) + expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1']) + }) + + it('a leave-running flip sweeps the whole mirror (cancel/terminal-drop cover)', () => { + const session = makeSession() + session.handleRunning(true) + session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1')) + session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2', true)) + session.handleRunning(false) + expect(session.getSnapshot().queue).toEqual([]) + }) + + it('a stale not-running relay on an idle session still sweeps replayed rows', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('e1'), queuedFrame('孤儿', 'p-1')) + session.handleRunning(false) // running already false: equality path must not skip the sweep + expect(session.getSnapshot().queue).toEqual([]) + }) +}) + +describe('queue reconnect semantics', () => { + it('session/subscribed re-baselines the mirror: stale rows drop, the following snapshot lands fresh', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('e1'), queuedFrame('旧连接', 'p-old')) + // New mux generation: subscribed arrives first on the same stream... + session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 }) + expect(session.getSnapshot().queue).toEqual([]) + // ...then the queue snapshot replays the live inbox. + session.handleMuxEnvelope(rid('e3'), queuedFrame('新基线', 'p-new')) + expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-new']) + }) + + it('resync must NOT clear the mirror (regression: onConnected races the mux baseline)', async () => { + const session = makeSession() + // Reconnect ordering that broke: mux opened first and already delivered + // the fresh generation's baseline; host stream (and with it onConnected → + // resync) lands after. The host never resends — clearing here left the + // dock empty until the next enqueue. + session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 }) + session.handleMuxEnvelope(rid('e2'), queuedFrame('新基线', 'p-fresh')) + await session.resync() + expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh']) + }) +}) + +describe('manager buffering of queued frames', () => { + it('buffers session/queued for uninstantiated sessions and replays before the running sync', () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queuedFrame('预热', 'p-b1') }) + // Instantiation replays the buffer; no summary exists, so no running sweep runs. + const session = manager.get(SID) + expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-b1']) + // The buffer is consumed: a second get must not double-replay. + expect(manager.get(SID).getSnapshot().queue).toHaveLength(1) + }) + + it('a not-running list summary sweeps replayed rows at instantiation', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok([{ sessionId: SID, updatedAt: 1, running: false }])) + const manager = new SessionManager(api) + await manager.refreshList() + manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queuedFrame('该扫掉', 'p-b2') }) + expect(manager.get(SID).getSnapshot().queue).toEqual([]) + }) + + it('subscribed re-baselines the uninstantiated buffer: stale queued frames drop, non-queue frames survive (regression: reconnect duplication)', () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + // Generation 1 baseline lands while the session is uninstantiated, along + // with a pending approval (never re-derivable from history). + manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queuedFrame('第一代', 'p-g1') }) + manager.handleMuxEnvelope({ + rpcId: rid('g1b'), + payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' }, + }) + // Reconnect: generation 2 replays subscribed + the SAME live queue entry. + manager.handleMuxEnvelope({ rpcId: rid('g2a'), payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 } }) + manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queuedFrame('第一代', 'p-g1') }) + const snapshot = manager.get(SID).getSnapshot() + // One queue row (no duplicate batch); the approval survived the re-baseline. + expect(snapshot.queue.map(r => r.key)).toEqual(['p-g1']) + expect(snapshot.pending.map(p => p.kind)).toEqual(['approval']) + }) +}) + +/** ok wrapper with a typed items payload (the shared helper pins value to never[]). */ +function ok(items: { sessionId: SessionId; updatedAt: number; running: boolean }[]) { + return { rpcId: rid(`ok-${items.length}`), result: { ok: true as const, value: { items: items as never[] } } } +} diff --git a/packages/client/runtime/tests/scope.spec.ts b/packages/client/runtime/tests/scope.spec.ts new file mode 100644 index 0000000000..f1c3847ce2 --- /dev/null +++ b/packages/client/runtime/tests/scope.spec.ts @@ -0,0 +1,84 @@ +/** + * Agent-scope primitive spec: the actx minted by createScope carries the + * tag and the dispatch filter itself, so plain cordis dispatch with the actx + * as subject routes by agent — same-agent tagged listeners receive, + * foreign-agent ones are filtered out, untagged listeners hear everything, + * and a subject-less root dispatch stays unfiltered. Scope-owned listeners + * dispose with the fiber. + */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import { createScope, scopeOf } from '../src/client/agents/scope.ts' + +const sid = (k: string): SessionId => k as SessionId + +declare module 'cordis' { + interface Events { + /** + * Test-only routed probe event. + * @param payload - marker payload. + * @mode bail + */ + 'test/scope-probe'(payload: { from: string }): true | undefined + } +} + +function bench() { + const root = new Context() + const a = createScope(root, sid('a')) + const b = createScope(root, sid('b')) + const seen: string[] = [] + const listen = (label: string, ctx: Context, answer?: true) => { + ctx.on('test/scope-probe', (payload) => { + seen.push(`${label}:${payload.from}`) + return answer + }) + } + return { root, a, b, seen, listen } +} + +describe('createScope', () => { + it('tags the ctx (scopeOf) and leaves the root untagged', () => { + const { root, a } = bench() + expect(scopeOf(a.ctx)).toBe(sid('a')) + expect(scopeOf(root)).toBeUndefined() + }) + + it('scoped dispatch reaches same-session and untagged listeners, never a foreign session', () => { + const { root, a, b, seen, listen } = bench() + listen('a', a.ctx) + listen('b', b.ctx) + listen('root', root) + a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' }) + expect(seen).toEqual(['a:a', 'root:a']) + seen.length = 0 + b.ctx.emit(b.ctx, 'test/scope-probe', { from: 'b' }) + expect(seen).toEqual(['b:b', 'root:b']) + }) + + it('bail answers the first same-scope listener and skips filtered foreign ones', () => { + const { a, b, listen } = bench() + listen('b', b.ctx, true) // registered first, but foreign → filtered out + expect(a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })).toBeUndefined() + listen('a', a.ctx, true) + expect(a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })).toBe(true) + }) + + it('a subject-less root dispatch is unfiltered (every listener hears it)', () => { + const { root, a, b, seen, listen } = bench() + listen('a', a.ctx) + listen('b', b.ctx) + listen('root', root) + root.emit('test/scope-probe', { from: 'root' }) + expect(seen).toEqual(['a:root', 'b:root', 'root:root']) + }) + + it('fiber disposal removes scope-owned listeners', async () => { + const { a, seen, listen } = bench() + listen('a', a.ctx) + await a.fiber.dispose() + a.ctx.emit(a.ctx, 'test/scope-probe', { from: 'late' }) + expect(seen).toEqual([]) + }) +}) diff --git a/packages/client/runtime/tests/session-intents.spec.ts b/packages/client/runtime/tests/session-intents.spec.ts deleted file mode 100644 index fca2c3e0b4..0000000000 --- a/packages/client/runtime/tests/session-intents.spec.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' -import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' -import { SessionsService } from '../src/client/sessions/service.ts' -import { WorkspacesService } from '../src/client/workspaces/service.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' - -const sid = (id: string): SessionId => id as SessionId -const wid = (id: string): WorkspaceId => id as WorkspaceId - -function workspace(id: string, sessionIds: SessionId[] = []): WorkspaceView { - return { - workspaceId: wid(id), - path: `/w/${id}`, - title: id, - sessionIds, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - } -} - -async function ready( - api: FakeApiClient, - workspaces: WorkspacesService, - sessions: SessionsService, - workspaceRows: WorkspaceView[], - sessionRows: { sessionId: SessionId; updatedAt: number; running: boolean }[] = [], -): Promise { - api.onWorkspaceList = () => Promise.resolve(ok({ items: workspaceRows as never[] })) - api.onList = () => Promise.resolve(ok({ items: sessionRows as never[] })) - await Promise.all([workspaces.refresh(), sessions.refresh()]) - await Promise.resolve() -} - -function services(api: FakeApiClient): { sessions: SessionsService; workspaces: WorkspacesService } { - const ctx = new Context() - const sessions = new SessionsService(ctx, api) - const workspaces = new WorkspacesService(ctx, api, sessions) - return { sessions, workspaces } -} - -function pendingPrompt(sessions: SessionsService, sessionId: SessionId) { - return sessions.binding(sessionId)?.session.getSnapshot().pendingPrompt -} - -describe('frontend Session and Workspace intents', () => { - it('resolves the initial intent into the most recently active Workspace', async () => { - const api = new FakeApiClient() - const { sessions, workspaces } = services(api) - const old = workspace('old', [sid('s-old')]) - const recent = workspace('recent', [sid('s-recent')]) - await ready(api, workspaces, sessions, [old, recent], [ - { sessionId: sid('s-old'), updatedAt: 1, running: false }, - { sessionId: sid('s-recent'), updatedAt: 2, running: false }, - ]) - expect(sessions.list.getSnapshot().intent).toMatchObject({ - target: { kind: 'workspace', workspaceId: 'recent' }, - phase: 'ready', - }) - expect(workspaces.list.getSnapshot().intent).toBeUndefined() - }) - - it('echoes updateIntent into the list snapshot in the same tick (controlled-input contract)', async () => { - const api = new FakeApiClient() - const { sessions, workspaces } = services(api) - await ready(api, workspaces, sessions, [workspace('target')]) - let notified = 0 - sessions.list.subscribe(() => { notified += 1 }) - // IME composition drives change events that a controlled textarea must see - // reflected before the handler returns; a microtask-deferred echo makes - // React roll the DOM back and the composition commits partial keystrokes. - sessions.updateIntent('你') - expect(sessions.list.getSnapshot().intent?.prompt).toBe('你') - expect(notified).toBeGreaterThan(0) - }) - - it('ignores updateIntent with no active Intent', async () => { - const api = new FakeApiClient() - const { sessions, workspaces } = services(api) - await ready(api, workspaces, sessions, [workspace('only', [sid('s-real')])], [ - { sessionId: sid('s-real'), updatedAt: 1, running: false }, - ]) - sessions.open(sid('s-real')) - expect(sessions.list.getSnapshot().intent).toBeUndefined() - let notified = 0 - sessions.list.subscribe(() => { notified += 1 }) - sessions.updateIntent('dropped') - expect(notified).toBe(0) - }) - - it('materializes zero-state Workspace and Session intents and retains a rejected first prompt', async () => { - const api = new FakeApiClient() - const { sessions, workspaces } = services(api) - await ready(api, workspaces, sessions, []) - expect(workspaces.list.getSnapshot().intent).toMatchObject({ name: 'workspace', phase: 'ready' }) - sessions.updateIntent('first prompt') - api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('created'), created: true })) - api.onCreate = payload => Promise.resolve(ok({ - sessionId: (payload as { sessionId: SessionId }).sessionId, - })) - api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'prompt offline', details: {} })) - workspaces.sendSession() - await vi.waitFor(() => { - const sessionId = sessions.list.getSnapshot().current as SessionId - expect(pendingPrompt(sessions, sessionId)).toMatchObject({ - text: 'first prompt', phase: 'failed', retry: 'send', - }) - }) - expect(api.callsOf('workspace.create')).toEqual([{ name: 'workspace' }]) - const create = api.callsOf('session.create')[0] as { workspaceId: WorkspaceId; sessionId: SessionId } - expect(create.workspaceId).toBe('created') - expect(api.callsOf('session.prompt')).toEqual([{ - sessionId: create.sessionId, - mode: 'queue', - content: [{ type: 'text', text: 'first prompt' }], - }]) - expect(workspaces.list.getSnapshot().intent).toBeUndefined() - }) - - it('turns Workspace attachment failure into a focused real Session and retries its prompt', async () => { - const api = new FakeApiClient() - const { sessions, workspaces } = services(api) - const target = workspace('target') - await ready(api, workspaces, sessions, [target]) - sessions.updateIntent('keep this') - api.onCreate = (payload) => { - const sessionId = (payload as { sessionId: SessionId }).sessionId - return Promise.resolve(err({ - code: 'workspace-attach-failed', - message: 'attach rejected', - details: { sessionId, workspaceId: target.workspaceId }, - })) - } - workspaces.sendSession() - await vi.waitFor(() => { - const snapshot = sessions.list.getSnapshot() - expect(snapshot.intent).toBeUndefined() - expect(pendingPrompt(sessions, snapshot.current as SessionId)).toMatchObject({ - text: 'keep this', phase: 'failed', retry: 'connect', - }) - }) - const published = sessions.list.getSnapshot().current as SessionId - const session = sessions.binding(published)!.session - session.updatePendingPrompt('retry this') - api.onCreate = () => Promise.resolve(ok({ sessionId: published })) - session.retryPendingPrompt() - await vi.waitFor(() => { - expect(pendingPrompt(sessions, published)).toBeNull() - }) - expect(api.callsOf('session.prompt').at(-1)).toMatchObject({ - sessionId: published, - content: [{ type: 'text', text: 'retry this' }], - }) - }) - - it('does not send after navigation while Session creation is in flight', async () => { - const api = new FakeApiClient() - const { sessions, workspaces } = services(api) - const target = workspace('target') - await ready(api, workspaces, sessions, [target]) - const gate = deferred>>() - api.onCreate = () => gate.promise - sessions.updateIntent('do not send yet') - workspaces.sendSession() - await vi.waitFor(() => { expect(api.callsOf('session.create')).toHaveLength(1) }) - const requested = (api.callsOf('session.create')[0] as { sessionId: SessionId }).sessionId - workspaces.startSession(target.workspaceId) - const replacement = sessions.list.getSnapshot().intent! - gate.resolve(ok({ sessionId: requested })) - await vi.waitFor(() => { - expect(pendingPrompt(sessions, requested)).toMatchObject({ - text: 'do not send yet', phase: 'failed', retry: 'send', - }) - }) - expect(api.callsOf('session.prompt')).toEqual([]) - expect(sessions.list.getSnapshot()).toMatchObject({ - current: replacement.sessionId, - intent: { sessionId: replacement.sessionId }, - }) - }) - - it('keeps a lost-response Intent and retries creation with its preallocated id', async () => { - const api = new FakeApiClient() - const { sessions, workspaces } = services(api) - const target = workspace('target') - await ready(api, workspaces, sessions, [target]) - sessions.updateIntent('preserve me') - api.onCreate = () => Promise.reject(new Error('response lost')) - workspaces.sendSession() - await vi.waitFor(() => { - expect(sessions.list.getSnapshot().intent?.error).toMatchObject({ step: 'session' }) - }) - const requested = sessions.list.getSnapshot().intent?.sessionId as SessionId - sessions.handleHostEnvelope({ - rpcId: 'published-later' as never, - payload: { type: 'host/session-added', sessionId: requested, cwd: target.path }, - }) - expect(sessions.list.getSnapshot()).toMatchObject({ - current: requested, - intent: { sessionId: requested, error: { step: 'session' } }, - }) - expect(sessions.intent()?.getSnapshot().pendingPrompt).toMatchObject({ - text: 'preserve me', phase: 'editing', - }) - - api.onCreate = payload => Promise.resolve(ok({ - sessionId: (payload as { sessionId: SessionId }).sessionId, - })) - workspaces.sendSession() - await vi.waitFor(() => { - expect(api.callsOf('session.create')).toHaveLength(2) - expect(api.callsOf('session.prompt')).toHaveLength(1) - expect(sessions.list.getSnapshot()).toMatchObject({ current: requested, intent: undefined }) - expect(pendingPrompt(sessions, requested)).toBeNull() - }) - expect(api.callsOf('session.create').map(call => (call as { sessionId: SessionId }).sessionId)) - .toEqual([requested, requested]) - }) -}) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index f5cf9a2138..6c223ef58b 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: return { api, session: new Session(SID, api) } } -function histResponse(events: SessionEvent[], hasMore = false) { +function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) { // history now returns HistoryEntry[] ({event, view?}); these tests are view-less. - return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) + return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } })) } describe('open', () => { @@ -153,6 +153,42 @@ describe('live event path', () => { }) }) + it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => { + const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }] + const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }] + const { session } = await opened() + expect(session.getSnapshot().todos).toEqual([]) + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.todoWrite(6, listA)) + expect(session.getSnapshot().todos).toEqual(listA) + feed(ev.todoWrite(7, listB)) + expect(session.getSnapshot().todos).toEqual(listB) + // Window replay converges on the same last snapshot (history contains both writes). + const replayed = makeSession() + replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)]) + await replayed.session.open() + expect(replayed.session.getSnapshot().todos).toEqual(listB) + }) + + it('seeds todos from the tail page projection when the last write precedes the window', async () => { + const list = [{ content: '窗口外的计划', status: 'in_progress' as const }] + // Cold open: the page window carries NO todo/write; the projection rides the response. + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list) + await session.open() + expect(session.getSnapshot().todos).toEqual(list) + // Paging an older window in must not clear the session-level projection. + api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false) + await session.loadOlder() + expect(session.getSnapshot().todos).toEqual(list) + // A later live write still overrides the seeded projection. + session.handleMuxEnvelope('r' as never, { + type: 'session/event', sessionId: SID, + event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]), + }) + expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }]) + }) + it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] @@ -166,6 +202,37 @@ describe('live event path', () => { const seqs = session.getSnapshot().nodes.map(n => n.seq) expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9 }) + + it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => { + const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 + expect(session.getSnapshot().todos).toEqual([]) + // The missed range contained a todo/write that the repulled page no longer + // covers; the response's session-level projection is the only carrier. + const current = [{ content: '断线期间写的', status: 'in_progress' as const }] + api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current) + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') }) + await vi.waitFor(() => { + expect(api.callsOf('session.history').length).toBe(2) + }) + await Promise.resolve() + expect(session.getSnapshot().todos).toEqual(current) + }) + + it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => { + // Live write lands, then the host crashes before persisting it: the + // authoritative log holds no todo/write, so the resync tail response + // carries no projection — an omitted field on a tail request is the empty + // list, not a missing carrier, and the rolled-back plan must disappear. + const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) + session.handleMuxEnvelope('r' as never, { + type: 'session/event', sessionId: SID, + event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]), + }) + expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }]) + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.resync() + expect(session.getSnapshot().todos).toEqual([]) + }) }) describe('paging', () => { diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index a6834071d0..44ab4ffb4f 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -10,7 +10,7 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' -import { FakeApiClient, ok } from './fake-api.ts' +import { FakeApiClient, deferred, ok } from './fake-api.ts' const sid = (s: string): SessionId => s as SessionId @@ -28,10 +28,12 @@ function bench(): Bench { } /** Refresh the manager list from programmable rows and flush the microtask batch. */ -async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean }[]): Promise { +type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean } + +async function feedList(b: Bench, rows: FeedRow[]): Promise { b.api.onList = () => Promise.resolve(ok({ items: rows.map(r => ({ - sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, + sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false, ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}), })), @@ -61,7 +63,7 @@ describe('list store projection', () => { it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never }) + b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', blank: true, sessionId: sid('s2') } as never }) await Promise.resolve() expect(b.svc.list.getSnapshot().ids).toContain('s2') }) @@ -77,7 +79,7 @@ describe('scope tree', () => { expect(scopeOf(scoped as Context)).toBe('s1') expect(scopeOf(b.ctx)).toBeUndefined() const binding = b.svc.binding(sid('s1')) - expect(binding?.session).toBe(b.svc.cell('s1')?.session) + expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session']) expect(b.svc.binding(sid('s1'))).toBe(binding) expect(binding?.ctx).toBe(scoped) }) @@ -184,20 +186,20 @@ describe('cell (render-layer session kit)', () => { it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - const cell = b.svc.cell('s1') - expect(cell).toBeDefined() - expect(cell?.sessionId).toBe('s1') - // The cell carries the observable; hook binding happens in React. - expect(cell?.session).toBe(b.svc.binding(sid('s1'))?.session) - expect(b.svc.cell('s1')).toBe(cell) - expect(b.svc.cell('ghost')).toBeUndefined() + const info = b.svc.provideInfo('s1') + expect(info).toBeDefined() + expect(info?.sessionId).toBe('s1') + // The bundle carries bare observables; hook binding happens in React. + expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) + expect(b.svc.provideInfo('s1')).toBe(info) + expect(b.svc.provideInfo('ghost')).toBeUndefined() }) - it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => { + it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) b.svc.open(sid('s1')) // staged - b.svc.cell('s2') // resolution only — must NOT move the stage + b.svc.provideInfo('s2') // resolution only — must NOT move the stage b.svc.binding(sid('s2')) await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives expect(b.svc.scope(sid('s1'))).toBeDefined() @@ -209,7 +211,7 @@ describe('cell (render-layer session kit)', () => { const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') // Resolution is addressing, not staging: no window pull. b.svc.scope(sid('s1')) - b.svc.cell('s1') + b.svc.provideInfo('s1') b.svc.binding(sid('s1')) expect(historyCalls()).toHaveLength(0) b.svc.open(sid('s1')) @@ -296,12 +298,24 @@ describe('create', () => { const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(SessionCreateError) expect(failure).toMatchObject({ - requestedSessionId: 'candidate', publishedSessionId: undefined, + requestedSessionId: 'candidate', rpcError: { code: 'internal', message: '爆了' }, }) }) - it('surfaces the definitely published id after Workspace attachment fails', async () => { + it('resolves with the session already listed and binding-resolvable (no flush wait)', async () => { + const b = bench() + b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('born') })) + const born = await b.svc.create({ workspaceId: 'ws' as never }) + // Synchronously after resolution — the draft hand-off contract: the + // create echo IS the entity entering the client's view (blank row + + // resolvable scope/binding), no notifier flush in between. + expect(b.svc.list.getSnapshot().byId[born]).toMatchObject({ id: 'born', blank: true }) + expect(b.svc.binding(born)).toBeDefined() + expect(b.svc.scope(born)).toBeDefined() + }) + + it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => { const b = bench() b.api.onCreate = () => Promise.resolve({ rpcId: 'attach' as never, @@ -318,11 +332,111 @@ describe('create', () => { sessionId: sid('published'), }).catch((error: unknown) => error) await Promise.resolve() + expect(failure).toBeInstanceOf(SessionCreateError) expect(failure).toMatchObject({ - publishedSessionId: 'published', requestedSessionId: 'published', + requestedSessionId: 'published', rpcError: { code: 'workspace-attach-failed' }, }) - expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published' }) + expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true }) + }) +}) + +describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => { + it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => { + const b = bench() + await feedList(b, []) + expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions + b.svc.handleHostEnvelope({ + rpcId: 'add' as never, + payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never, + }) + await Promise.resolve() + const scoped = b.svc.scope(sid('s-new')) + expect(scoped).toBeDefined() + expect(scopeOf(scoped as Context)).toBe('s-new') + b.svc.handleHostEnvelope({ + rpcId: 'rm' as never, + payload: { type: 'host/session-removed', sessionId: sid('s-new') }, + }) + await Promise.resolve() + expect(b.svc.scope(sid('s-new'))).toBeUndefined() + }) +}) + +describe('blank mirror', () => { + it('flips blank=false from the running:true status frame (cross-client conversion)', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true }]) + expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true }) + b.svc.handleHostEnvelope({ + rpcId: 'st' as never, + payload: { type: 'host/session-status', sessionId: sid('s1'), running: true }, + }) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true }) + // The instantiated Session mirrors the same flip. + expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false) + }) + + it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }]) + const session = b.svc.binding(sid('s1'))!.session + expect(session.getSnapshot().blank).toBe(true) + const gate = deferred>>() + b.api.onPrompt = () => gate.promise + const send = session.prompt([{ type: 'text', text: 'hi' }], 'queue') + // In flight: still blank (the flip point is the success response, which + // proves the user message reached the host log). + expect(session.getSnapshot().blank).toBe(true) + gate.resolve(ok({ accepted: true as const })) + await send + expect(session.getSnapshot().blank).toBe(false) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false }) + }) + + it('keeps a rejected first prompt blank: hidden and still reusable', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }]) + const session = b.svc.binding(sid('s1'))!.session + b.api.onPrompt = () => Promise.resolve({ + rpcId: 'busy' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'agent busy', details: {} } }, + } as never) + const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue') + expect(result.ok).toBe(false) + // No flip on failure: local stays aligned with the host authority + // (events.length still 0), so the session stays hidden and reusable. + expect(session.getSnapshot().blank).toBe(true) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true }) + }) + + it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => { + const b = bench() + await feedList(b, []) + b.svc.handleHostEnvelope({ + rpcId: 'add' as never, + payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never, + }) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: true }) + // Reconnect re-pull: the summary's blank=false wins (authoritative alignment). + await feedList(b, [{ id: 's-new', blank: false, cwd: '/w/a' }]) + expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: false }) + }) + + it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true }]) + const session = b.svc.binding(sid('s1'))!.session + await session.prompt([{ type: 'text', text: 'hi' }], 'queue') + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false }) + // The next list pull still claims blank (host hasn't logged the message yet). + await feedList(b, [{ id: 's1', blank: true }]) + expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false) }) }) diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 069cc788d5..97bcb50f0a 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -97,13 +97,17 @@ function fakeWorkspaces() { return { list: { getSnapshot: () => state, subscribe: () => () => undefined } } } -/** Minimal sessions face for the host seam (list observable + cell). */ +/** Minimal sessions face for the host seam (list observable + provide bundle). */ function fakeSessions() { const state = { ids: [], byId: {}, current: undefined as string | undefined } return { list: { getSnapshot: () => state, subscribe: () => () => undefined }, - cell: (id: string) => (id === 'known' - ? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } } + provideInfo: (id: string) => (id === 'known' + ? { + sessionId: id, + hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }, + props: {}, + } : undefined), } } @@ -228,13 +232,13 @@ describe('host face', () => { expect(host.entriesOf('t.host')).toHaveLength(0) }) - it('exposes sessions list/current/cell (current riding the list snapshot)', async () => { + it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => { const bench = await boot() const host = captureHost(bench) expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] }) expect(host.sessions.current.getSnapshot()).toBeUndefined() - expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' }) - expect(host.sessions.cell('ghost')).toBeUndefined() + expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' }) + expect(host.sessions.provideInfo('ghost')).toBeUndefined() }) it('exposes the independent Workspace list source', async () => { diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts new file mode 100644 index 0000000000..01a6691a4b --- /dev/null +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -0,0 +1,55 @@ +/** + * Wire-to-typed-event bridge (web input-triggers cut 1): host/commands-changed + * → ctx 'commands/changed'; each established connection generation → + * ctx 'connection/reset' (the forced cache-invalidation broadcast). + */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' +import * as RuntimeClient from '../src/client/index.ts' +import { FakeApiClient } from './fake-api.ts' + +interface Bench { + ctx: Context + sinks: ConnectionSinks | undefined +} + +async function mount(): Promise { + const ctx = new Context() + const api = new FakeApiClient() + const bench: Bench = { ctx, sinks: undefined } + const handle: ConnectionHandle = { + api, + start: (sinks) => { + bench.sinks = sinks + return { stop: () => {} } + }, + } + ctx.reflect.provide('connection', handle) + await ctx.plugin(RuntimeClient).await() + return bench +} + +describe('wire event bridge', () => { + it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => { + const bench = await mount() + let changed = 0 + bench.ctx.on('commands/changed', () => { changed++ }) + bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } }) + expect(changed).toBe(1) + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r2' as never, + payload: { type: 'host/session-status', sessionId: 's1' as never, running: true }, + }) + expect(changed).toBe(1) + }) + + it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => { + const bench = await mount() + let resets = 0 + bench.ctx.on('connection/reset', () => { resets++ }) + bench.sinks?.onConnected?.() + bench.sinks?.onConnected?.() // second generation after a reconnect + expect(resets).toBe(2) + }) +}) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index c2c2c62b86..210c04d896 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -17,38 +17,6 @@ function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-0 } describe('WorkspaceManager', () => { - it('owns, materializes, retries, supersedes, and discards Workspace objects with local intents', async () => { - const api = new FakeApiClient() - const manager = new WorkspaceManager(api) - manager.startIntent('first') - expect(manager.getSnapshot().intent).toEqual({ name: 'first', phase: 'ready' }) - - api.onWorkspaceCreate = () => Promise.resolve(err({ - code: 'workspace-name-conflict', message: 'taken', details: { name: 'first' }, - } as never)) - await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: false }) - expect(manager.getSnapshot().intent).toMatchObject({ name: 'first', phase: 'ready' }) - expect(typeof manager.getSnapshot().intent?.error).toBe('string') - - const gate = deferred>>() - api.onWorkspaceCreate = () => gate.promise - const stale = manager.materializeIntent() - expect(manager.getSnapshot().intent?.phase).toBe('creating') - manager.startIntent('replacement') - gate.resolve(ok({ workspace: workspace('first'), created: true })) - await stale - expect(manager.getSnapshot().intent).toEqual({ name: 'replacement', phase: 'ready' }) - - api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('replacement'), created: true })) - await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: true }) - expect(manager.getSnapshot().intent).toBeUndefined() - await expect(manager.materializeIntent()).resolves.toBeUndefined() - manager.discardIntent() - manager.startIntent('discarded') - manager.discardIntent() - expect(manager.getSnapshot().intent).toBeUndefined() - }) - it('replays changed frames over hydration and keeps established order on refresh', async () => { const api = new FakeApiClient() const gate = deferred>>() @@ -108,10 +76,52 @@ describe('WorkspaceManager', () => { ok: false, error: { code: 'internal', message: 'create transport' }, }) }) + + it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const manager = new WorkspaceManager(api) + const hydration = manager.refresh() + manager.handleHostEnvelope({ + rpcId: 'removed' as never, + payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, + }) + gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] })) + await hydration + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + + manager.handleHostEnvelope({ + rpcId: 'late-change' as never, + payload: { type: 'host/workspace-changed', workspace: workspace('gone') }, + }) + manager.handleHostEnvelope({ + rpcId: 'duplicate-remove' as never, + payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, + }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + }) + + it('removes from the unary delete echo while a refresh is in flight', async () => { + const api = new FakeApiClient() + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] })) + const manager = new WorkspaceManager(api) + await manager.refresh() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const refresh = manager.refresh() + + await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }]) + expect(manager.getSnapshot().items).toEqual([]) + gate.resolve(ok({ items: [workspace('gone')] as never[] })) + await refresh + expect(manager.getSnapshot().items).toEqual([]) + }) }) describe('WorkspacesService', () => { - it('feeds SessionManager readiness and recent-Workspace targeting without changing Host order', async () => { + it('feeds readiness and recent-Workspace targeting without changing Host order', async () => { const ctx = new Context() const api = new FakeApiClient() const sessions = new SessionsService(ctx, api) @@ -127,7 +137,7 @@ describe('WorkspacesService', () => { expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined }) api.onList = () => Promise.resolve(ok({ - items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false }] as never[], + items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false, blank: false }] as never[], })) await sessions.refresh() await Promise.resolve() @@ -136,12 +146,65 @@ describe('WorkspacesService', () => { baselinesReady: true, recentWorkspaceId: 'active', }) - expect(sessions.list.getSnapshot().intent).toMatchObject({ - target: { kind: 'workspace', workspaceId: 'active' }, - }) expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active']) }) + it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('alpha'), workspace('beta')] as never[], + })) + api.onList = () => Promise.resolve(ok({ + items: [ + // Blank session already parked in alpha (cwd == workspace path canon). + { sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }, + // Non-blank sibling in beta must never be reused. + { sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' }, + ] as never[], + })) + await Promise.all([workspaces.refresh(), sessions.refresh()]) + await Promise.resolve() + + // Hit: same workspace → the parked blank session comes back, no create RPC. + await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank') + expect(api.callsOf('session.create')).toEqual([]) + // Resolution guarantee: the id is binding-resolvable synchronously. + expect(sessions.binding(sid('s-blank'))).toBeDefined() + + // Miss: beta has only a non-blank session → host create with workspaceId. + api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') })) + await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh') + expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }]) + // Same guarantee on the create arm (draft hand-off writes the machine pre-open). + expect(sessions.binding(sid('s-fresh'))).toBeDefined() + + // Unknown workspace fails loud instead of silently creating in nowhere. + await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/) + }) + + it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) + api.onList = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[], + })) + await Promise.all([workspaces.refresh(), sessions.refresh()]) + await Promise.resolve() + const session = sessions.binding(sid('s-blank'))!.session + api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'agent busy', details: {} }) as never) + await session.prompt([{ type: 'text', text: 'hi' }], 'queue') + await Promise.resolve() + // Failure leaves blank intact, so the same session is still the reuse hit. + await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank') + expect(api.callsOf('session.create')).toEqual([]) + }) + it('returns created Workspaces and preserves Host business errors', async () => { const ctx = new Context() const api = new FakeApiClient() @@ -154,4 +217,20 @@ describe('WorkspacesService', () => { })) await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/) }) + + it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) + await workspaces.refresh() + await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined() + expect(workspaces.list.getSnapshot().items).toEqual([]) + + api.onWorkspaceDelete = () => Promise.resolve(err({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' }, + })) + await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) + }) }) diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml new file mode 100644 index 0000000000..6d15efd511 --- /dev/null +++ b/packages/client/ui-command/README.i18n.yaml @@ -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 +README.md: 17bc4edd7d002d6bba4470c9418a9179b2cb131b +README.zh.md: 1291556409b993aa893e102386f75c45bb195adf diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md new file mode 100644 index 0000000000..17bc4edd7d --- /dev/null +++ b/packages/client/ui-command/README.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-client-ui-command + +English | [中文](README.zh.md) + +Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md). + +`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. + +`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. + +`PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`. + +The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration. + +## Model Experience + +Indirectly, through the host `command.execute` RPC this package's dispatch and `claim.submit` paths trigger: a matched command's handler mutates host domain state that other packages project into the next request (the `/plan` handler flips plan mode, whose owning package injects its `plan:policy` system-prompt section), while the command line itself, the detached result, and every menu/notice rendering stay client-side and never enter the session log. + +#### KV Cache effect + +None directly; this package neither assembles nor sends a provider request. Command handlers it triggers may change what the owning host packages contribute to the next request's system prompt (a section appearing or disappearing replaces earlier request tokens and invalidates the provider prefix from that point), but that effect is owned and documented by each command's host package. + +## Known Limitations and Deferred Work + +- **The popupSelect shell has no shipped business consumer** — model selection (host `selectModel`) is the design's reference case and lands with its own feature work; until then the shell is exercised by package tests only. +- **Detached-result notices fall back to the console off-session** — the fire-and-forget paths route results to the triggering session's composer via `SessionInput.notify`; after session teardown the console line is the only remaining surface. diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md new file mode 100644 index 0000000000..1291556409 --- /dev/null +++ b/packages/client/ui-command/README.zh.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-client-ui-command + +[English](README.md) | 中文 + +客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。 + +`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 + +`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key:每个会话恒为 agent-backed,因此 `command.list({sessionId})` 是唯一的寻址形状,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 + +`PopupSelectController`(`src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。 + +`/client` 导出表层是插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的契约类型;壳组件本身是 overlay 注册的内部实现。 + +## 模型体验 + +间接影响,途径是本包的派发与 `claim.submit` 路径触发的 host `command.execute` RPC:匹配命中的命令,其 handler 会修改 host 领域状态,其他包再把该状态投影进下一个请求(`/plan` 的 handler 翻转 plan 模式,其归属包注入 `plan:policy` 系统提示词 section),而命令行本身、detached result 与所有菜单/notice 渲染都留在客户端,永不进入会话日志。 + +#### KV Cache 影响 + +无直接影响;该包既不组装也不发送提供方请求。它触发的命令 handler 可能改变归属 host 包对下一个请求系统提示词的贡献(某个 section 的出现或消失会替换较早的请求 token,并使提供方前缀从该点起失效),但这一影响由各命令的 host 包拥有并记录。 + +## 已知限制与暂缓事项 + +- **popupSelect 壳还没有已上架的业务消费者**:模型选择(host `selectModel`)是设计的参照用例,将随其自身的功能工作落地;在此之前,壳只由包测试演练。 +- **脱离会话后,detached result 的 notice 回退到 console**:fire-and-forget 路径经 `SessionInput.notify` 把结果送到触发会话的编辑器;会话拆除后,console 输出行是仅剩的呈现面。 diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json new file mode 100644 index 0000000000..c34f34dc18 --- /dev/null +++ b/packages/client/ui-command/package.json @@ -0,0 +1,72 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-command", + "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-slash", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-command/src/client/PopupSelectView.module.css b/packages/client/ui-command/src/client/PopupSelectView.module.css new file mode 100644 index 0000000000..c3ab051223 --- /dev/null +++ b/packages/client/ui-command/src/client/PopupSelectView.module.css @@ -0,0 +1,98 @@ +/* Official popupSelect shell card: menu-surface tokens (same family as + * ui-primitives Menu.module.css — figma MenuDropdown r12 / hairline / + * shadow-lv3), anchored by the conversation.input.overlay slot. */ + +.card { + /* The overlay anchor is a zero-height strip on the composer card's top + edge; entries float themselves above it (same rule as MenuView). */ + position: absolute; + bottom: calc(100% + 4px); + left: 0; + z-index: 100; + padding: 4px; + display: flex; + flex-direction: column; + min-width: 220px; + max-height: 320px; + overflow-y: auto; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 12px; + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); + outline: none; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; + font-size: 13px; + color: var(--dsw-alias-text-primary); +} + +.rowActive { + background: var(--dsw-alias-fill-hover); +} + +.label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.detail { + font-size: 12px; + color: var(--dsw-alias-text-tertiary); + white-space: nowrap; +} + +.check { + display: inline-flex; + color: var(--dsw-alias-text-secondary); +} + +.status { + padding: 8px; + font-size: 12px; + color: var(--dsw-alias-text-tertiary); +} + +.search { + margin: 2px 2px 4px; + padding: 6px 8px; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 8px; + background: transparent; + font-size: 13px; + color: var(--dsw-alias-text-primary); + outline: none; +} + +.error { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + font-size: 12px; + color: var(--dsw-alias-state-error-primary); +} + +.errorText { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.retry { + padding: 2px 8px; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 6px; + background: transparent; + font-size: 12px; + color: var(--dsw-alias-text-primary); + cursor: pointer; +} diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx new file mode 100644 index 0000000000..9d2807ded1 --- /dev/null +++ b/packages/client/ui-command/src/client/PopupSelectView.tsx @@ -0,0 +1,133 @@ +/** + * Official popupSelect shell: renders one session's PopupSelectController + * store into the conversation.input.overlay anchor. Unlike the slash menu + * (combobox — textarea keeps focus), this shell HOLDS focus while open: the + * inner search input takes focus, plain typing filters the loaded options + * locally, Enter/↑↓ drive the filtered highlight, Escape dismisses back to + * the composer, and ←→ keep the search input's native caret. Any pointer + * interaction outside the box dismisses (the click's own target takes + * focus). Closed state renders null; the overlay slot stays mounted. + */ +import { useEffect, useRef } from 'react' +import { useSyncExternalStore } from 'react' +import clsx from 'clsx' +import { IconCheckOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { filterOptions } from './popup.ts' +import type { PopupSelectController } from './popup.ts' +import css from './PopupSelectView.module.css' + +/** Injected business face of the popupSelect overlay entry. */ +export interface PopupSelectInjected { + /** The session's shell controller (state store + verbs; the view never touches the open-context type). */ + popup: PopupSelectController +} + +/** + * Render the popupSelect shell overlay entry. + * @param props - injected face: the session's shell controller. + * @returns the select card while open; null while closed. + */ +export function PopupSelectView({ popup }: PopupSelectInjected) { + const state = useSyncExternalStore( + fn => popup.state.subscribe(fn), + () => popup.state.getSnapshot(), + ) + const cardRef = useRef(null) + const searchRef = useRef(null) + + // Focus ownership: the search input grabs on open (the design's + // transient-layer rule), and ANY outside pointer interaction dismisses — + // capture phase so a click landing anywhere else (textarea included) + // closes the shell before its own handlers run; that click's target then + // takes focus naturally, so no focusComposer here. + useEffect(() => { + if (!state.open) return + searchRef.current?.focus() + const onPointerDown = (ev: PointerEvent): void => { + if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return + popup.dismiss() + } + document.addEventListener('pointerdown', onPointerDown, true) + return () => { document.removeEventListener('pointerdown', onPointerDown, true) } + }, [state.open, popup]) + + if (!state.open) return null + + const rows = filterOptions(state.options, state.search) + + const onKeyDown = (ev: React.KeyboardEvent): void => { + // ArrowLeft/ArrowRight fall through on purpose: the search input keeps + // its native caret movement. + switch (ev.key) { + case 'ArrowDown': + ev.preventDefault() + popup.move(1) + return + case 'ArrowUp': + ev.preventDefault() + popup.move(-1) + return + case 'Enter': + ev.preventDefault() + void popup.select(state.active) + return + case 'Escape': + ev.preventDefault() + popup.dismiss({ focusComposer: true }) + return + default: + } + } + + return ( +

    + { popup.setSearch(ev.currentTarget.value) }} + /> + {state.error !== null && ( +
    + {state.error} + {state.status === 'failed' && ( + + )} +
    + )} + {state.status === 'pending' &&
    Loading options…
    } + {state.submitting &&
    Applying…
    } + {state.status === 'ready' && rows.length === 0 &&
    No options
    } + {state.status === 'ready' && ( +
    + {rows.map((option, index) => ( +
    { void popup.select(index) }} + onMouseEnter={() => { popup.highlight(index) }} + > + {option.label} + {option.detail !== undefined && {option.detail}} + {option.active === true && } +
    + ))} +
    + )} +
    + ) +} diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts new file mode 100644 index 0000000000..a9a1116664 --- /dev/null +++ b/packages/client/ui-command/src/client/contract.ts @@ -0,0 +1,55 @@ +/** + * Frozen contract of the client command surface. Types only. The + * CommandService (`ctx.command`) implements this face; business packages + * consume `register` alone. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' + +/** One option row of a popupSelect shell. */ +export interface SelectOption { + readonly id: string + readonly label: string + readonly detail?: string + readonly active?: boolean +} + +/** + * Business registration for the popupSelect command kind. Data is + * self-served: options/onSelect use the business package's own protocol. + * The shell component is owned by ui-command; business never sees it. Both + * callbacks receive the ClientSessionContext captured at popup open. + */ +export type CommandUiSpec = { + readonly kind: 'popupSelect' + options(session: ClientSessionContext, signal: AbortSignal): Promise + onSelect(option: SelectOption, session: ClientSessionContext): void | Promise +} + +/** + * One client-owned command contribution: a slash-menu entry whose behavior + * lives entirely on the client (no host descriptor). Merged with the host + * catalog by name — a collision with a host command fails loud at candidate + * synthesis, never shadows. + */ +export interface CommandContribution { + /** Command name without the leading slash (unique across contributions). */ + readonly name: string + /** Menu row description. */ + readonly description: string + /** Capability filter, called with a fresh projection per candidate pass. */ + available(session: ClientSessionContext): boolean + /** The command's UI behavior (this phase: popupSelect only). */ + readonly ui: CommandUiSpec +} + +/** The `ctx.command` service face visible to business packages. */ +export interface CommandServiceContract { + /** + * Register one client command contribution; effect disposer. Duplicate + * names throw at registration. + */ + register(contribution: CommandContribution): () => void + /** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */ + popupFor(actx: ClientContext): unknown +} diff --git a/packages/client/ui-command/src/client/directory.ts b/packages/client/ui-command/src/client/directory.ts new file mode 100644 index 0000000000..a7cdca7fd8 --- /dev/null +++ b/packages/client/ui-command/src/client/directory.ts @@ -0,0 +1,175 @@ +/** + * Command-directory cache keyed by session: one entry per served catalog — + * every session is agent-backed, so `command.list({sessionId})` is the only + * address shape. Each entry keeps the single-flight / soft-hard invalidation + * / epoch-guard behavior of the original global cache; the session-key axis + * is the only extra dimension. + */ +import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' + +/** command.list success value, derived so the wire type authority stays in apiproxy. */ +type ListValue = Extract>['result'], { ok: true }>['value'] + +/** One host command descriptor as served to the client. */ +export type CommandDescriptor = ListValue['commands'][number] + +/** + * cold = never pulled; pending = pull in flight with nothing servable; + * ready = snapshot serving (a soft-invalidate repull keeps this status); + * failed = last winning pull rejected, snapshot dropped. + */ +export type DirectoryStatus = 'cold' | 'pending' | 'ready' | 'failed' + +/** Injected pull (the service binds command.list off the root connection). */ +export type FetchCommands = (sessionId: SessionId) => Promise + +/** One session key's cache cell. */ +class Entry { + state: DirectoryStatus = 'cold' + commands: readonly CommandDescriptor[] = [] + /** Bumped at each pull start; only the latest pull may publish its outcome. */ + epoch = 0 + lastError: unknown + waiters: Array<() => void> = [] +} + +/** The session-keyed directory cache. Plain class — the owning service wires events and RPC. */ +export class CommandDirectory { + private readonly entries = new Map() + + constructor(private readonly fetchCommands: FetchCommands) {} + + /** + * Current cache status for one session. + * @param sessionId - session key. + * @returns the entry status (cold when never touched). + */ + status(sessionId: SessionId): DirectoryStatus { + return this.entries.get(sessionId)?.state ?? 'cold' + } + + /** + * Synchronous exact-name lookup over one session's hot snapshot. + * @param sessionId - session key. + * @param name - command name without the leading slash. + * @returns the descriptor, or undefined when absent or the entry is not ready. + */ + resolve(sessionId: SessionId, name: string): CommandDescriptor | undefined { + const entry = this.entries.get(sessionId) + if (entry === undefined || entry.state !== 'ready') return undefined + return entry.commands.find(c => c.name === name) + } + + /** Soft invalidation (commands-changed): background repull on every touched key; ready snapshots keep serving. */ + invalidateAll(): void { + for (const key of this.entries.keys()) void this.refresh(key) + } + + /** + * Hard reset on reconnect: every entry drops its snapshot (the agent world + * may have changed shape across the generation) and prewarms. + */ + resetConnected(): void { + for (const [key, entry] of this.entries) { + entry.state = 'cold' + entry.commands = [] + void this.refresh(key) + } + } + + /** + * Fire-and-forget prewarm of one session (the command source's scope-birth + * warm hook lands here). + * @param sessionId - session key. + */ + warm(sessionId: SessionId): void { + const entry = this.entry(sessionId) + if (entry.state === 'cold' || entry.state === 'failed') void this.refresh(sessionId) + } + + /** + * Start one pull for one session. Publishes ready/failed only while it is + * still the key's latest pull (epoch guard); a ready snapshot is not + * demoted while the pull flies. + * @param sessionId - session key. + * @returns settled when this pull's outcome is published or discarded. + */ + async refresh(sessionId: SessionId): Promise { + const entry = this.entry(sessionId) + const epoch = ++entry.epoch + if (entry.state !== 'ready') entry.state = 'pending' + try { + const commands = await this.fetchCommands(sessionId) + if (epoch !== entry.epoch) return + entry.commands = commands + entry.state = 'ready' + entry.lastError = undefined + } catch (error) { + if (epoch !== entry.epoch) return + entry.commands = [] + entry.state = 'failed' + entry.lastError = error + } finally { + if (epoch === entry.epoch) notifyWaiters(entry) + } + } + + /** + * Strong-wait until one session's catalog is servable (the enter- + * adjudication "directory must be reached" rule): ready returns at once; + * cold/failed launch a fresh pull; pending joins the flying one. Rejects + * when the awaited pull fails or the signal aborts. + * @param sessionId - session key. + * @param signal - attempt-scoped abort (the SubmitAttempt signal). + * @returns the hot command snapshot. + */ + async ensureReady(sessionId: SessionId, signal: AbortSignal): Promise { + const entry = this.entry(sessionId) + while (true) { + if (entry.state === 'ready') return entry.commands + if (entry.state !== 'pending') void this.refresh(sessionId) + await settled(entry, signal) + if (entry.state === 'failed') { + throw new Error(`command directory warmup failed: ${entry.lastError instanceof Error ? entry.lastError.message : String(entry.lastError)}`) + } + // Still pending (the awaited pull was superseded) → wait for the winner. + } + } + + private entry(sessionId: SessionId): Entry { + let entry = this.entries.get(sessionId) + if (entry === undefined) { + entry = new Entry() + this.entries.set(sessionId, entry) + } + return entry + } +} + +/** One settlement tick for one entry: resolves at the next winning publish, rejects on abort. */ +function settled(entry: Entry, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortReason(signal)) + return new Promise((resolve, reject) => { + const waiter = (): void => { + signal.removeEventListener('abort', onAbort) + resolve() + } + const onAbort = (): void => { + entry.waiters = entry.waiters.filter(w => w !== waiter) + reject(abortReason(signal)) + } + signal.addEventListener('abort', onAbort, { once: true }) + entry.waiters.push(waiter) + }) +} + +function notifyWaiters(entry: Entry): void { + const woken = entry.waiters + entry.waiters = [] + for (const wake of woken) wake() +} + +/** Normalize an abort into an Error rejection. */ +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new Error('command directory wait aborted') +} diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts new file mode 100644 index 0000000000..4765dc7d86 --- /dev/null +++ b/packages/client/ui-command/src/client/index.ts @@ -0,0 +1,61 @@ +/** + * Command UI plugin, browser half: CommandService (`ctx.command`) owning the + * capability-keyed directory cache, the '/' command source, the client + * contribution registry, and the per-session popupSelect controllers; the + * popupSelect shell self-registers into conversation.input.overlay with + * per-session resolution. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the 'conversation.input.overlay' SlotMap declaration (the +// key's owner) into this program so the overlay registration below typechecks +// against the real declaration — no runtime edge to ui-conversation. +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CommandService } from './service.ts' +import type { PopupSelectInjected } from './PopupSelectView.tsx' +import { PopupSelectView } from './PopupSelectView.tsx' + +export { CommandService } from './service.ts' +export { CommandDirectory } from './directory.ts' +export type { CommandDescriptor, DirectoryStatus } from './directory.ts' +export { filterOptions, PopupSelectController } from './popup.ts' +export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' +export type { PopupSelectInjected } from './PopupSelectView.tsx' +export type { + CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption, +} from './contract.ts' + +declare module 'cordis' { + interface Context { + command: CommandService + } +} + +/** Required services: the '/' source registry plus the scope + wire faces the service reads. */ +export const inject = ['slash', 'sessions', 'connection'] + +/** + * Client plugin body: mount the service, then register the popupSelect shell + * into the input overlay once its declarer is up. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.plugin(CommandService) + // Conditional mount, same seam as ui-slash's MenuView registration: + // 'conversation.input.overlay' is declared by the conversation composer + // entry, and the conversation service's presence is the registration-safe + // signal that the declaration is on the ledger. + ctx.inject(['slots', 'conversation', 'command', 'sessions'], (scope: ClientContext) => { + const command = scope.command + const sessions = scope.sessions + scope.effect(() => scope.slots.register({ + name: 'conversation.input.overlay', + id: 'command-popup', + order: 1, + inject: (sessionId): PopupSelectInjected => { + const actx = sessions.scope(sessionId) + if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`) + return { popup: command.popupFor(actx) } + }, + }, PopupSelectView), 'ui-command: popupSelect overlay registration') + }) +} diff --git a/packages/client/ui-command/src/client/popup.ts b/packages/client/ui-command/src/client/popup.ts new file mode 100644 index 0000000000..c2d30f3213 --- /dev/null +++ b/packages/client/ui-command/src/client/popup.ts @@ -0,0 +1,251 @@ +/** + * Headless popupSelect shell state (design §10): one controller per client + * session, owned by CommandService's per-session map and torn down by the + * session scope disposer. The shell is a transient layer (never in the input + * state machine): it loads options once, filters them locally against the + * shell's own search text, and settles a selection through the context + * captured at open time. Draft consumption and composer focus are injected + * callbacks — the session wiring dispatches the consume-token event (the + * Input side owns the span/bare-token CAS guard) and focuses the composer; + * the controller never touches the input machine. + */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { SelectOption } from './contract.ts' + +/** + * The command token segment snapshotted at shell-open time, replayed to the + * injected {@link PopupSelectDeps.consume} callback after a successful + * selection. The Input side guards it: a menu-path span consumes iff draftRev + * is unchanged, an enter-path line iff the trimmed draft still equals the + * bare token. + */ +export type TokenSegment = + | { readonly via: 'menu'; readonly span: TokenSpan } + | { readonly via: 'enter'; readonly token: string } + +/** + * Structural business spec the shell settles against — the popupSelect half + * of CommandUiSpec, generic in the context value the opener captures (the + * session wiring passes its session projection; the controller only carries + * it from open() to the callbacks). + */ +export interface PopupSpec { + /** Load the option rows once per open (retry after failure reuses the same signal). */ + options(context: TCtx, signal: AbortSignal): Promise + /** Settle the picked option against the open-time context. */ + onSelect(option: SelectOption, context: TCtx): void | Promise +} + +/** Injected session-wiring callbacks of one controller (tests pass fakes). */ +export interface PopupSelectDeps { + /** + * Consume the open-time token segment after a successful onSelect (the + * wiring dispatches the consume-token event to the opening session). + * @param segment - the open-time token segment snapshot. + * @returns whether the token was consumed; false (CAS miss) is benign and + * never retried. + */ + consume(segment: TokenSegment): boolean + /** Return focus to the session composer (successful settle and Escape close paths). */ + focusComposer(): void +} + +/** Popup shell state (the shell component renders from here; closed = render null). */ +export interface PopupState { + readonly open: boolean + /** Command name the shell is open for (null while closed). */ + readonly command: string | null + /** Options-load lifecycle; 'failed' keeps the shell open for retry(). */ + readonly status: 'pending' | 'ready' | 'failed' + /** Options as loaded — never re-fetched per keystroke; views render {@link filterOptions} over them. */ + readonly options: readonly SelectOption[] + /** Local filter text over the loaded options. */ + readonly search: string + /** Highlight index into the filtered row list (0 when empty/pending). */ + readonly active: number + /** A select() settlement is in flight: further select/search/highlight no-op until it settles. */ + readonly submitting: boolean + /** Surfaced settlement failure (options load or onSelect); null when none. */ + readonly error: string | null +} + +const CLOSED: PopupState = { + open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null, +} + +/** + * Filter option rows against the shell's local search text (case-insensitive + * substring over label and detail; blank search keeps every row). + * @param options - the loaded rows. + * @param search - the shell's search text. + * @returns the rows the shell shows and highlights over. + */ +export function filterOptions(options: readonly SelectOption[], search: string): readonly SelectOption[] { + const query = search.trim().toLowerCase() + if (query === '') return options + return options.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false)) +} + +/** One open shell's bindings (spec + open-time context + segment snapshot + options-fetch abort). */ +interface OpenBinding { + readonly command: string + readonly spec: PopupSpec + readonly context: TCtx + readonly segment: TokenSegment + readonly abort: AbortController +} + +/** The shell's error-strip line for a settlement failure. */ +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * Headless controller of one session's popupSelect shell. Late settlements + * lose their write rights through binding identity: dismiss/dispose/reopen + * swap the binding, so a settling options fetch or onSelect that no longer + * matches writes nothing and consumes nothing. + */ +export class PopupSelectController { + /** Shell state store (the overlay component subscribes here). */ + readonly state: SnapshotStore = createSnapshotStore(CLOSED) + private binding: OpenBinding | null = null + + /** + * @param deps - session-wiring callbacks (token consumption + composer focus). + */ + constructor(private readonly deps: PopupSelectDeps) {} + + /** + * Open the shell for one command: publish pending state and fetch options + * once through the business spec. A reopen supersedes the previous shell + * (its options fetch is aborted, its late settlements are dropped). + * @param command - command name the shell serves. + * @param spec - the registered popupSelect spec. + * @param context - open-time context snapshot, handed verbatim to options/onSelect. + * @param segment - open-time token segment snapshot for post-select consumption. + */ + open(command: string, spec: PopupSpec, context: TCtx, segment: TokenSegment): void { + this.binding?.abort.abort() + const binding: OpenBinding = { command, spec, context, segment, abort: new AbortController() } + this.binding = binding + this.state.set({ ...CLOSED, open: true, command }) + this.load(binding) + } + + /** Run the one options fetch of a binding; settlement rights die with the binding. */ + private load(binding: OpenBinding): void { + binding.spec.options(binding.context, binding.abort.signal).then( + (options) => { + if (this.binding !== binding) return + this.state.set({ ...this.state.getSnapshot(), status: 'ready', options, active: 0, error: null }) + }, + (error: unknown) => { + if (this.binding !== binding) return + console.error(`[ui-command] popupSelect options failed for /${binding.command}:`, error) + this.state.set({ ...this.state.getSnapshot(), status: 'failed', options: [], active: 0, error: errorText(error) }) + }, + ) + } + + /** Re-run a failed options fetch (search survives; no-op unless status is 'failed'). */ + retry(): void { + const binding = this.binding + const s = this.state.getSnapshot() + if (binding === null || !s.open || s.status !== 'failed') return + this.state.set({ ...s, status: 'pending', error: null }) + this.load(binding) + } + + /** + * Replace the local search text (pure local filter — the provider is never + * re-queried) and rebase the highlight onto the new filtered list. + * @param search - the shell search input's text. + */ + setSearch(search: string): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || search === s.search) return + this.state.set({ ...s, search, active: 0 }) + } + + /** + * Move the highlight across the filtered rows (wraps around; no-op unless + * options are ready and no selection is in flight). + * @param dir - +1 down, -1 up. + */ + move(dir: 1 | -1): void { + const s = this.state.getSnapshot() + if (!s.open || s.status !== 'ready' || s.submitting) return + const rows = filterOptions(s.options, s.search) + if (rows.length === 0) return + const active = (s.active + dir + rows.length) % rows.length + this.state.set({ ...s, active }) + } + + /** + * Set the highlight directly (pointer hover; no-op unless ready, idle, and + * in filtered range). + * @param index - filtered-row index. + */ + highlight(index: number): void { + const s = this.state.getSnapshot() + if (!s.open || s.status !== 'ready' || s.submitting) return + if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return + this.state.set({ ...s, active: index }) + } + + /** + * Select one filtered row: single-flight — the first call enters + * `submitting` and later calls no-op until it settles. Success consumes the + * open-time token segment (a false CAS answer is benign), closes, and + * returns focus to the composer. Failure keeps the shell open with search, + * highlight, and token intact, surfaces the error, and re-arms select as + * the retry. + * @param index - filtered-row index (callers pass the highlight or the clicked row). + * @returns settled when the attempt has closed the shell or surfaced its failure. + */ + async select(index: number): Promise { + const binding = this.binding + const s = this.state.getSnapshot() + if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return + const option = filterOptions(s.options, s.search)[index] + if (option === undefined) return + this.state.set({ ...s, submitting: true, error: null }) + try { + await binding.spec.onSelect(option, binding.context) + } catch (error) { + console.error(`[ui-command] popupSelect onSelect failed for /${binding.command}:`, error) + if (this.binding !== binding) return // dismissed/reopened/disposed while onSelect flew + this.state.set({ ...this.state.getSnapshot(), submitting: false, error: errorText(error) }) + return + } + if (this.binding !== binding) return // late success: no state write, no consumption + this.deps.consume(binding.segment) + this.binding = null + this.state.set(CLOSED) + this.deps.focusComposer() + } + + /** + * Close the shell; aborts a flying options fetch and revokes settlement + * rights. An outside pointer interaction dismisses plainly (the click's own + * target takes focus); Escape passes focusComposer to return focus explicitly. + * @param opts - focusComposer: also restore composer focus (Escape path). + */ + dismiss(opts?: { readonly focusComposer?: boolean }): void { + if (this.binding === null) return + this.binding.abort.abort() + this.binding = null + this.state.set(CLOSED) + if (opts?.focusComposer === true) this.deps.focusComposer() + } + + /** Scope-teardown disposer: abort in-flight work and clear state (no focus side effect). */ + dispose(): void { + this.binding?.abort.abort() + this.binding = null + this.state.set(CLOSED) + } +} diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts new file mode 100644 index 0000000000..df59ad2dcd --- /dev/null +++ b/packages/client/ui-command/src/client/service.ts @@ -0,0 +1,292 @@ +/** + * CommandService (`ctx.command`): the '/' command source over the + * session-keyed directory, the client-contribution registry, and the + * per-session popupSelect controllers. Candidate synthesis merges the host + * catalog with contributions by availability, then query/position filtering; + * a host/contribution name collision fails loud. Every execute addresses the + * session's agent by sessionId — sessions are always agent-backed. + */ +import { Service } from 'cordis' +import type { Context } from 'cordis' +import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { + CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick, + SlashServiceContract, SubmitOutcome, +} from '@deepseek-ai/dsh-client-ui-slash/client' +import type { CommandContribution, CommandServiceContract } from './contract.ts' +import type { CommandDescriptor } from './directory.ts' +import { CommandDirectory } from './directory.ts' +import { PopupSelectController } from './popup.ts' +import type { TokenSegment } from './popup.ts' + +/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */ +interface LiveState { + readonly contributions: Map + readonly popups: Map> +} + +/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ +export class CommandService extends Service implements CommandServiceContract { + static inject = ['slash', 'sessions', 'connection'] + + private readonly directory: CommandDirectory + private readonly live: LiveState = { contributions: new Map(), popups: new Map() } + + /** + * @param ctx - owning root context (plugin fiber; the service registers + * itself as `command` and follows that fiber's lifetime). + */ + constructor(ctx: Context) { + super(ctx, 'command') + const connection = ctx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error('ui-command: connection service unavailable') + this.directory = new CommandDirectory(async (sessionId) => { + const { result } = await connection.api.commands.list({ sessionId }) + if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`) + return result.value.commands + }) + const slash = ctx.get('slash') as SlashServiceContract | undefined + if (slash === undefined) throw new Error('ui-command: slash service unavailable') + ctx.effect(() => slash.registerSource({ + trigger: '/', + name: 'command', + candidates: (session, req) => this.candidates(session, req), + onPick: pick => this.dispatch(pick), + matchSpace: (session, token) => this.matchSpace(session, token), + matchEnter: (session, line, signal) => this.matchEnter(session, line, signal), + warm: (session) => { this.directory.warm(session.sessionId) }, + }), 'command: slash source') + ctx.on('commands/changed', () => { this.directory.invalidateAll() }) + ctx.on('connection/reset', () => { this.directory.resetConnected() }) + } + + /** + * Register one client command contribution; effect disposer (rides the + * caller's fiber). Duplicate names throw. + * @param contribution - the contribution (descriptor + availability + popup spec). + * @returns the disposer removing the registration. + */ + register(contribution: CommandContribution): () => void { + const dispose = this.ctx.effect(() => { + const { contributions } = this.live + if (contributions.has(contribution.name)) { + throw new Error(`ui-command: duplicate contribution for /${contribution.name}`) + } + contributions.set(contribution.name, contribution) + return () => { contributions.delete(contribution.name) } + }, 'command.register()') + return () => { void dispose() } + } + + /** + * Resolve the per-session popup controller (lazy; dies with the session + * scope). The controller's consume callback dispatches the scoped + * consume-token event back to this session; focusComposer reaches the + * composer through the overlay slot currency. + * @param actx - session-scope ctx. + * @returns the resident controller. + */ + popupFor(actx: ClientContext): PopupSelectController { + const sessions = this.sessions() + const id = sessions.scopeOf(actx) + if (id === undefined) throw new Error('command.popupFor requires a session scope') + const { popups } = this.live + const existing = popups.get(id) + if (existing !== undefined) return existing + const controller = new PopupSelectController({ + consume: segment => actx.bail(actx, 'slash/input-consume-token', { + guard: segment.via === 'menu' + ? { kind: 'span', span: segment.span } + : { kind: 'bare-token', token: segment.token }, + }) === true, + focusComposer: () => { this.focusHooks.get(id)?.() }, + }) + popups.set(id, controller) + actx.effect(() => () => { + controller.dispose() + popups.delete(id) + this.focusHooks.delete(id) + }, 'command: session popup') + return controller + } + + /** Composer focus hooks by session (the overlay wiring binds the textarea focus here). */ + private readonly focusHooks = new Map void>() + + /** + * Bind one session's composer-focus hook (overlay slot wiring; unbind on unmount). + * @param id - session id. + * @param focus - textarea focus callback. + * @returns the unbind disposer. + */ + bindComposerFocus(id: SessionId, focus: () => void): () => void { + this.focusHooks.set(id, focus) + return () => { + if (this.focusHooks.get(id) === focus) this.focusHooks.delete(id) + } + } + + /** Menu candidates: host catalog + contribution availability, then query/position filtering. */ + private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise { + const list = await this.directory.ensureReady(session.sessionId, req.signal) + const rows: SlashCandidate[] = [] + const seen = new Set() + for (const c of list) { + seen.add(c.name) + rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) }) + } + for (const contribution of this.live.contributions.values()) { + if (!contribution.available(session)) continue + if (seen.has(contribution.name)) { + throw new Error(`ui-command: contribution /${contribution.name} collides with a host command`) + } + rows.push({ name: contribution.name, description: contribution.description }) + } + return rows + .filter(c => c.name.startsWith(req.query)) + .filter(c => req.position === 'leading' || c.hint === undefined) + } + + /** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */ + private dispatch(pick: SlashPick): PickOutcome { + const name = pick.candidate.name + const contribution = this.live.contributions.get(name) + if (contribution !== undefined && contribution.available(pick.session)) { + this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span }) + return 'handled' + } + const desc = this.directory.resolve(pick.session.sessionId, name) + if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss + if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) } + // Menu-pick execute consumes the trigger span before the detached run + // (scoped event; the input owns the CAS guard). + this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span }) + this.runDetached(desc, pick.session, `/${name}`) + return 'handled' + } + + /** Decision table, space column: hot-key sync check; only host leadingInput claims. */ + private matchSpace(session: ClientSessionContext, token: string): PickOutcome { + if (!token.startsWith('/')) return undefined + const name = token.slice(1) + if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space + const desc = this.directory.resolve(session.sessionId, name) + if (desc === undefined || desc.input === undefined) return undefined + return { claim: this.leadingClaim(desc, session) } + } + + /** + * Decision table, enter column. Strong-waits the session's catalog (a + * warmup failure rejects — never a silent downgrade). Contributions and + * bare host commands act on the bare token only; leadingInput claims + * args-tolerant. + */ + private async matchEnter(session: ClientSessionContext, line: string, signal: AbortSignal): Promise { + const trimmed = line.trim() + if (!trimmed.startsWith('/')) return undefined + const ws = trimmed.search(/\s/) + const token = ws === -1 ? trimmed : trimmed.slice(0, ws) + const bare = ws === -1 + const name = token.slice(1) + if (name === '') return undefined + const contribution = this.live.contributions.get(name) + if (contribution !== undefined && contribution.available(session)) { + if (!bare) return undefined + this.openPopup(contribution, session, { via: 'enter', token }) + return 'handled' + } + await this.directory.ensureReady(session.sessionId, signal) + const desc = this.directory.resolve(session.sessionId, name) + if (desc === undefined) return undefined + if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) } + if (!bare) return undefined + this.consumeVia(session.sessionId, { via: 'enter', token }) + this.runDetached(desc, session, trimmed) + return 'handled' + } + + /** Open the session's popup for one contribution (menu pick / bare enter). */ + private openPopup( + contribution: CommandContribution, + session: ClientSessionContext, + segment: TokenSegment, + ): void { + const actx = this.scopeFor(session.sessionId) + if (actx === undefined) return + this.popupFor(actx).open(contribution.name, contribution.ui, session, segment) + } + + /** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */ + private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim { + const token = `/${desc.name} ` + return { + token, + ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), + submit: (args, _actx) => this.execute(session, token + args), + } + } + + /** The command.execute transaction, addressed to the session's agent. */ + private async execute( + session: ClientSessionContext, + line: string, + ): Promise { + const connection = this.ctx.get('connection') as ConnectionHandle + const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line }) + if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) + if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` } + const detached = result.value.result + return detached === undefined + ? { kind: 'success' } + : { kind: detached.kind, ...(detached.text !== undefined ? { text: detached.text } : {}) } + } + + /** + * Fire-and-forget execute for the internal ('handled') paths. The detached + * result surfaces as a notice routed to the triggering session's composer, + * so a late result lands on its own session after a switch. + */ + private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void { + void this.execute(session, line).then( + (outcome) => { + if (outcome.kind === 'error') this.noticeFor(session.sessionId, desc.name, 'error', outcome.text ?? `/${desc.name} failed`) + else if (outcome.text !== undefined) this.noticeFor(session.sessionId, desc.name, 'info', outcome.text) + }, + (error: unknown) => { + this.noticeFor(session.sessionId, desc.name, 'error', error instanceof Error ? error.message : String(error)) + }, + ) + } + + /** Dispatch a consume-token event to one session (menu-pick / bare-enter execute paths). */ + private consumeVia(id: SessionId, segment: TokenSegment): void { + const actx = this.scopeFor(id) + if (actx === undefined) return + actx.bail(actx, 'slash/input-consume-token', { + guard: segment.via === 'menu' + ? { kind: 'span', span: segment.span } + : { kind: 'bare-token', token: segment.token }, + }) + } + + /** Route a detached result to the session's composer notice channel (scope gone = attempt died with it). */ + private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void { + const actx = this.scopeFor(id) + if (actx === undefined) return + const conversation = actx.get('conversation') + if (conversation === undefined) return + conversation.input.for(actx).notify(level, text) + } + + /** id → actx interchange (registered exchange point: this service coordinates for projection-only sources). */ + private scopeFor(id: SessionId): ClientContext | undefined { + return this.sessions().scope(id) + } + + private sessions(): SessionsService { + const sessions = this.ctx.get('sessions') + if (sessions === undefined) throw new Error('ui-command: sessions service unavailable') + return sessions + } +} diff --git a/packages/client/ui-command/src/css-modules.d.ts b/packages/client/ui-command/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-command/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-command/src/index.ts b/packages/client/ui-command/src/index.ts new file mode 100644 index 0000000000..29e446e339 --- /dev/null +++ b/packages/client/ui-command/src/index.ts @@ -0,0 +1,10 @@ +/** + * Command UI plugin, node half. Pure UI plugin: the empty apply exists so + * the plugin appears in the host cordis.yml / Loader; the browser half ships + * via exports["./client"], discovered through the package.json dshClient + * declaration. The host command registry itself mounts separately + * (bootHost + CommandService). + */ + +/** Host plugin body — no host-side behavior for the command UI plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-command/src/invariant.ts b/packages/client/ui-command/src/invariant.ts new file mode 100644 index 0000000000..2d38b762a9 --- /dev/null +++ b/packages/client/ui-command/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-command`. + * @module @deepseek-ai/dsh-client-ui-command/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-command' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-command-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a browser-side source over the wire command + * directory — it emits no cordis events and owns no cross-plugin mutable + * state; dispatch and cache behavior are asserted by this package's specs. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..03c0df2d50 --- /dev/null +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -0,0 +1,83 @@ +/** + * ui-command browser half on a real cordis Context with fake slash/slots + * faces and real session scopes: the plugin body mounts CommandService as + * `command`, the popupSelect shell registers into conversation.input.overlay + * once the conversation seam is up with a per-session inject (sessionId → + * scope → popupFor; unknown id fails loud), both fold up on fiber disposal + * (HMR safety), and the service satisfies the frozen CommandServiceContract. + */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { CommandServiceContract } from '../src/client/contract.ts' +import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx' +import { apply, CommandService, inject } from '../src/client/index.ts' + +const sid = (k: string): SessionId => k as SessionId + +async function bench() { + const ctx = new Context() + const sources = new Map() + const overlays = new Map() + ctx.provide('slash', { + registerSource(src: SlashSource) { + sources.set(`${src.trigger} ${src.name}`, src) + return () => { sources.delete(`${src.trigger} ${src.name}`) } + }, + }) + const scopes = new Map() + ctx.provide('sessions', { + scope: (id: SessionId) => scopes.get(id), + scopeOf: (c: Context) => scopeOf(c), + }) + ctx.provide('connection', { api: { commands: { list: () => Promise.resolve({ result: { ok: true, value: { commands: [] } } }) } } }) + ctx.provide('slots', { + register(options: { name: string; id?: string; inject?: unknown }) { + const key = `${options.name}#${options.id ?? ''}` + overlays.set(key, { inject: options.inject }) + return () => { overlays.delete(key) } + }, + }) + ctx.provide('conversation', {}) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const mint = (key: string) => { + const handle = createScope(ctx, sid(key)) + scopes.set(sid(key), handle.ctx) + return handle + } + return { ctx, fiber, sources, overlays, mint } +} + +describe('apply', () => { + it('declares the services it binds', () => { + expect(inject).toEqual(['slash', 'sessions', 'connection']) + }) + + it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => { + const { ctx, fiber, sources, overlays } = await bench() + const command = ctx.get('command') + expect(command).toBeInstanceOf(CommandService) + // Frozen-contract conformance (compile-time check rides the assignment). + const contract: CommandServiceContract = command as CommandService + expect(typeof contract.register).toBe('function') + expect(typeof contract.popupFor).toBe('function') + expect([...sources.keys()]).toEqual(['/ command']) + expect([...overlays.keys()]).toEqual(['conversation.input.overlay#command-popup']) + await fiber.dispose() + expect(sources.size).toBe(0) + expect(overlays.size).toBe(0) + }) + + it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => { + const { ctx, overlays, mint } = await bench() + const command = ctx.get('command') as CommandService + const scope = mint('s1') + const entry = overlays.get('conversation.input.overlay#command-popup')! + const injectEntry = entry.inject as (sessionId: SessionId) => PopupSelectInjected + expect(injectEntry(sid('s1')).popup).toBe(command.popupFor(scope.ctx)) + expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/) + }) +}) diff --git a/packages/client/ui-command/tests/directory.spec.ts b/packages/client/ui-command/tests/directory.spec.ts new file mode 100644 index 0000000000..c1c0b5a75d --- /dev/null +++ b/packages/client/ui-command/tests/directory.spec.ts @@ -0,0 +1,293 @@ +/** + * CommandDirectory unit tests over the session-key axis: per-key status + * transitions and epoch guard, key isolation across sessions, soft + * invalidation (invalidateAll), the reconnect hard reset (resetConnected: + * every entry drops its snapshot and prewarms), the warm hook's cold/failed + * gate, and the per-key ensureReady strong-wait policy. + */ +import { describe, expect, it } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { CommandDescriptor } from '../src/client/directory.ts' +import { CommandDirectory } from '../src/client/directory.ts' + +const sid = (k: string): SessionId => k as SessionId +const S1 = sid('s1') +const S2 = sid('s2') + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +const CMDS: CommandDescriptor[] = [ + { name: 'plan', description: 'plan mode' }, + { name: 'goal', description: 'set goal', input: { hint: 'goal text' } }, +] + +const S2_CMDS: CommandDescriptor[] = [ + ...CMDS, + { name: 'attach', description: 'attach a file', input: { hint: 'path' } }, +] + +/** Directory over per-key pull queues: each fetch appends a hand-settled deferred. */ +function bench() { + const pulls = new Map>>>() + const calls: SessionId[] = [] + const dir = new CommandDirectory((key) => { + calls.push(key) + const d = deferred() + const queue = pulls.get(key) ?? [] + queue.push(d) + pulls.set(key, queue) + return d.promise + }) + const pull = (key: SessionId, i: number) => { + const d = pulls.get(key)?.[i] + if (d === undefined) throw new Error(`no pull #${i} for ${key}`) + return d + } + return { dir, pull, calls, countOf: (key: SessionId) => pulls.get(key)?.length ?? 0 } +} + +describe('status and resolve (per key)', () => { + it('starts cold and resolves nothing', () => { + const { dir } = bench() + expect(dir.status(S1)).toBe('cold') + expect(dir.resolve(S1, 'plan')).toBeUndefined() + }) + + it('serves exact-name lookups once ready, undefined for unknown names', async () => { + const { dir, pull } = bench() + const refreshed = dir.refresh(S1) + expect(dir.status(S1)).toBe('pending') + pull(S1, 0).resolve(CMDS) + await refreshed + expect(dir.status(S1)).toBe('ready') + expect(dir.resolve(S1, 'goal')).toEqual(CMDS[1]) + expect(dir.resolve(S1, 'nope')).toBeUndefined() + }) + + it('drops the snapshot and records failure on a failed pull', async () => { + const { dir, pull } = bench() + const refreshed = dir.refresh(S1) + pull(S1, 0).reject(new Error('boom')) + await refreshed + expect(dir.status(S1)).toBe('failed') + expect(dir.resolve(S1, 'plan')).toBeUndefined() + }) + + it('keys are isolated: one session catalog landing leaves another cold', async () => { + const { dir, pull } = bench() + const refreshed = dir.refresh(S1) + pull(S1, 0).resolve(CMDS) + await refreshed + expect(dir.status(S2)).toBe('cold') + expect(dir.resolve(S2, 'plan')).toBeUndefined() + + const other = dir.refresh(S2) + pull(S2, 0).resolve(S2_CMDS) + await other + expect(dir.resolve(S2, 'attach')).toBeDefined() + expect(dir.resolve(S1, 'attach')).toBeUndefined() + }) +}) + +describe('epoch guard (per key)', () => { + it('a superseded pull cannot overwrite the newer one (old resolves after new)', async () => { + const { dir, pull } = bench() + const first = dir.refresh(S1) + const second = dir.refresh(S1) + pull(S1, 1).resolve(CMDS) + await second + expect(dir.resolve(S1, 'plan')).toBeDefined() + pull(S1, 0).resolve([{ name: 'stale', description: 'old world' }]) + await first + expect(dir.resolve(S1, 'stale')).toBeUndefined() + expect(dir.resolve(S1, 'plan')).toBeDefined() + }) + + it('a superseded failure cannot demote the newer success', async () => { + const { dir, pull } = bench() + const first = dir.refresh(S1) + const second = dir.refresh(S1) + pull(S1, 1).resolve(CMDS) + await second + pull(S1, 0).reject(new Error('late failure')) + await first + expect(dir.status(S1)).toBe('ready') + expect(dir.resolve(S1, 'plan')).toBeDefined() + }) + + it('epochs are per key: one session supersede leaves another session epoch alone', async () => { + const { dir, pull } = bench() + const one = dir.refresh(S1) + void dir.refresh(S2) + void dir.refresh(S2) // supersedes the s2 pull only + pull(S1, 0).resolve(CMDS) + await one + expect(dir.status(S1)).toBe('ready') + }) +}) + +describe('invalidateAll (commands-changed soft)', () => { + it('repulls every touched key in the background while ready snapshots keep serving', async () => { + const { dir, pull, countOf } = bench() + const a = dir.refresh(S1) + const b = dir.refresh(S2) + pull(S1, 0).resolve(CMDS) + pull(S2, 0).resolve(S2_CMDS) + await Promise.all([a, b]) + + dir.invalidateAll() + expect(countOf(S1)).toBe(2) + expect(countOf(S2)).toBe(2) + expect(dir.status(S1)).toBe('ready') + expect(dir.resolve(S2, 'attach')).toBeDefined() + + pull(S1, 1).resolve([{ name: 'fresh', description: 'new world' }]) + await Promise.resolve() + await Promise.resolve() + expect(dir.resolve(S1, 'fresh')).toBeDefined() + expect(dir.resolve(S1, 'plan')).toBeUndefined() + }) + + it('an untouched directory invalidates to nothing (no keys, no pulls)', () => { + const { dir, calls } = bench() + dir.invalidateAll() + expect(calls).toEqual([]) + }) +}) + +describe('resetConnected (reconnect hard)', () => { + it('every entry drops its snapshot immediately and prewarms', async () => { + const { dir, pull, countOf } = bench() + const a = dir.refresh(S1) + const b = dir.refresh(S2) + pull(S1, 0).resolve(CMDS) + pull(S2, 0).resolve(S2_CMDS) + await Promise.all([a, b]) + + dir.resetConnected() + // Hard: the agent world may have changed shape across the generation. + expect(dir.status(S1)).toBe('pending') + expect(dir.resolve(S1, 'plan')).toBeUndefined() + expect(dir.status(S2)).toBe('pending') + expect(dir.resolve(S2, 'attach')).toBeUndefined() + expect(countOf(S1)).toBe(2) + expect(countOf(S2)).toBe(2) + + pull(S1, 1).resolve(CMDS) + pull(S2, 1).resolve(S2_CMDS) + await Promise.resolve() + await Promise.resolve() + expect(dir.status(S1)).toBe('ready') + expect(dir.resolve(S2, 'attach')).toBeDefined() + }) +}) + +describe('warm', () => { + it('launches a pull from cold, again after failure, and never over pending/ready', async () => { + const { dir, pull, countOf } = bench() + dir.warm(S1) + expect(countOf(S1)).toBe(1) + dir.warm(S1) // pending → no second pull + expect(countOf(S1)).toBe(1) + + pull(S1, 0).reject(new Error('boom')) + await Promise.resolve() + await Promise.resolve() + expect(dir.status(S1)).toBe('failed') + dir.warm(S1) // failed → retry + expect(countOf(S1)).toBe(2) + + pull(S1, 1).resolve(CMDS) + await Promise.resolve() + await Promise.resolve() + dir.warm(S1) // ready → no-op + expect(countOf(S1)).toBe(2) + }) + + it('warms keys independently', () => { + const { dir, countOf } = bench() + dir.warm(S2) + expect(countOf(S2)).toBe(1) + expect(countOf(S1)).toBe(0) + }) +}) + +describe('ensureReady (per key)', () => { + const signal = () => new AbortController().signal + + it('returns the hot snapshot at once when ready', async () => { + const { dir, pull, countOf } = bench() + const warm = dir.refresh(S1) + pull(S1, 0).resolve(CMDS) + await warm + await expect(dir.ensureReady(S1, signal())).resolves.toEqual(CMDS) + expect(countOf(S1)).toBe(1) + }) + + it('launches a pull from cold and resolves on arrival, without touching other keys', async () => { + const { dir, pull, countOf } = bench() + const wait = dir.ensureReady(S2, signal()) + expect(dir.status(S2)).toBe('pending') + pull(S2, 0).resolve(S2_CMDS) + await expect(wait).resolves.toEqual(S2_CMDS) + expect(countOf(S1)).toBe(0) + }) + + it('joins a flying pull instead of starting a second one', async () => { + const { dir, pull, countOf } = bench() + void dir.refresh(S1) + const wait = dir.ensureReady(S1, signal()) + expect(countOf(S1)).toBe(1) + pull(S1, 0).resolve(CMDS) + await expect(wait).resolves.toEqual(CMDS) + }) + + it('rejects when the awaited pull fails (no silent downgrade)', async () => { + const { dir, pull } = bench() + const wait = dir.ensureReady(S1, signal()) + pull(S1, 0).reject(new Error('warmup boom')) + await expect(wait).rejects.toThrow('command directory warmup failed: warmup boom') + }) + + it('retries from failed state with a fresh pull', async () => { + const { dir, pull } = bench() + const first = dir.ensureReady(S1, signal()) + pull(S1, 0).reject(new Error('boom')) + await expect(first).rejects.toThrow() + const second = dir.ensureReady(S1, signal()) + pull(S1, 1).resolve(CMDS) + await expect(second).resolves.toEqual(CMDS) + }) + + it('rejects on abort while waiting', async () => { + const { dir } = bench() + const ac = new AbortController() + const wait = dir.ensureReady(S1, ac.signal) + ac.abort(new Error('attempt superseded')) + await expect(wait).rejects.toThrow('attempt superseded') + }) + + it('rejects immediately on an already-aborted signal', async () => { + const { dir, pull } = bench() + const warm = dir.refresh(S1) + pull(S1, 0).reject(new Error('irrelevant')) + await warm + const ac = new AbortController() + ac.abort() // bare abort: the DOMException reason is itself an Error and travels as-is + await expect(dir.ensureReady(S1, ac.signal)).rejects.toThrow(/aborted/) + }) + + it('keeps waiting across a superseded pull and settles on the winner', async () => { + const { dir, pull } = bench() + const wait = dir.ensureReady(S1, signal()) + void dir.refresh(S1) // supersedes pull #0 with pull #1 + pull(S1, 0).resolve([{ name: 'stale', description: 'loser' }]) + pull(S1, 1).resolve(CMDS) + await expect(wait).resolves.toEqual(CMDS) + }) +}) diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx new file mode 100644 index 0000000000..9afd432d37 --- /dev/null +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +/** + * PopupSelectView interaction spec (design §10.2): the search input takes + * focus on open and plain typing filters locally, ↑↓ move the filtered + * highlight while ←→ stay native to the input, Enter selects single-flight, + * Escape dismisses back through focusComposer, outside pointerdown dismisses + * plainly, and the submitting/failed states render pending text and a + * working retry button. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SelectOption } from '../src/client/contract.ts' +import type { PopupSpec, TokenSegment } from '../src/client/popup.ts' +import { PopupSelectController } from '../src/client/popup.ts' +import { PopupSelectView } from '../src/client/PopupSelectView.tsx' + +afterEach(cleanup) + +const OPTIONS: SelectOption[] = [ + { id: 'dark', label: 'Dark' }, + { id: 'light', label: 'Light', active: true }, + { id: 'sepia', label: 'Sepia', detail: 'warm' }, +] + +const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } + +function spec(overrides: Partial> = {}): PopupSpec { + return { + options: () => Promise.resolve(OPTIONS), + onSelect: () => undefined, + ...overrides, + } +} + +async function mountOpen(overrides: Partial> = {}, consumeResult = true) { + const consume = vi.fn((_segment: TokenSegment) => consumeResult) + const focusComposer = vi.fn() + const popup = new PopupSelectController({ consume, focusComposer }) + const view = render() + await act(async () => { + popup.open('theme', spec(overrides), 'ctx-A', SEGMENT) + await Promise.resolve() + }) + return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: 'Filter options' }) } +} + +function rowLabels(): string[] { + return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent!) +} + +describe('PopupSelectView', () => { + it('renders null while closed, opens with focus in the search input', async () => { + const popup = new PopupSelectController({ consume: () => true, focusComposer: () => {} }) + const view = render() + expect(view.container.childElementCount).toBe(0) + await act(async () => { + popup.open('theme', spec(), 'ctx-A', SEGMENT) + await Promise.resolve() + }) + const search = screen.getByRole('textbox', { name: 'Filter options' }) + expect(document.activeElement).toBe(search) + expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia']) + }) + + it('typing filters rows locally and rebases the highlight', async () => { + const options = vi.fn(() => Promise.resolve(OPTIONS)) + const { search } = await mountOpen({ options }) + act(() => { fireEvent.change(search, { target: { value: 'li' } }) }) + expect(rowLabels()).toEqual(['Light']) + expect(screen.getByRole('option').getAttribute('aria-selected')).toBe('true') + expect(options).toHaveBeenCalledTimes(1) + act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) }) + expect(screen.queryByRole('option')).toBeNull() + expect(screen.queryByText('No options')).not.toBeNull() + }) + + it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => { + const { search } = await mountOpen() + act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) }) + let options = screen.getAllByRole('option') + expect(options[1]!.getAttribute('aria-selected')).toBe('true') + act(() => { fireEvent.keyDown(search, { key: 'ArrowUp' }) }) + options = screen.getAllByRole('option') + expect(options[0]!.getAttribute('aria-selected')).toBe('true') + // fireEvent returns false when preventDefault was called: arrow left/right must NOT be intercepted. + expect(fireEvent.keyDown(search, { key: 'ArrowLeft' })).toBe(true) + expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true) + }) + + it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => { + const seen: Array<{ option: SelectOption; context: string }> = [] + const { view, search, consume, focusComposer } = await mountOpen({ + onSelect: (option, context) => { seen.push({ option, context }) }, + }) + act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) }) + await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) }) + expect(seen).toEqual([{ option: OPTIONS[1], context: 'ctx-A' }]) + expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(focusComposer).toHaveBeenCalledTimes(1) + expect(view.container.childElementCount).toBe(0) + }) + + it('click selects a row; mouseenter moves the highlight', async () => { + const seen: SelectOption[] = [] + const { view } = await mountOpen({ onSelect: (option) => { seen.push(option) } }) + const options = screen.getAllByRole('option') + act(() => { fireEvent.mouseEnter(options[2]!) }) + expect(screen.getAllByRole('option')[2]!.getAttribute('aria-selected')).toBe('true') + await act(async () => { fireEvent.click(options[2]!) }) + expect(seen).toEqual([OPTIONS[2]]) + expect(view.container.childElementCount).toBe(0) + }) + + it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => { + let release!: () => void + const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve })) + const { search, consume } = await mountOpen({ onSelect }) + await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) }) + expect(screen.queryByText('Applying…')).not.toBeNull() + expect((search as HTMLInputElement).readOnly).toBe(true) + await act(async () => { + fireEvent.keyDown(search, { key: 'Enter' }) + fireEvent.click(screen.getAllByRole('option')[1]!) + }) + expect(onSelect).toHaveBeenCalledTimes(1) + await act(async () => { + release() + await Promise.resolve() + }) + expect(consume).toHaveBeenCalledTimes(1) + }) + + it('a failed options load shows the error with a Retry button that reloads', async () => { + let attempts = 0 + await mountOpen({ + options: () => { + attempts += 1 + return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(OPTIONS) + }, + }) + expect(screen.getByRole('alert').textContent).toContain('directory down') + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await Promise.resolve() + }) + expect(attempts).toBe(2) + expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia']) + }) + + it('an onSelect failure keeps the shell open with the error strip and no retry button (re-select is the retry)', async () => { + const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) }) + await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) }) + expect(screen.getByRole('alert').textContent).toContain('host rejected') + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + expect(consume).not.toHaveBeenCalled() + expect(screen.getAllByRole('option').length).toBe(3) + }) + + it('Escape dismisses and restores composer focus', async () => { + const { view, search, focusComposer } = await mountOpen() + act(() => { fireEvent.keyDown(search, { key: 'Escape' }) }) + expect(view.container.childElementCount).toBe(0) + expect(focusComposer).toHaveBeenCalledTimes(1) + }) + + it('an outside pointerdown dismisses without focusComposer; an inside one does not dismiss', async () => { + const { view, focusComposer } = await mountOpen() + act(() => { fireEvent.pointerDown(screen.getAllByRole('option')[0]!) }) + expect(view.container.childElementCount).not.toBe(0) + act(() => { fireEvent.pointerDown(document.body) }) + expect(view.container.childElementCount).toBe(0) + expect(focusComposer).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-command/tests/popup.spec.ts b/packages/client/ui-command/tests/popup.spec.ts new file mode 100644 index 0000000000..87a1070a40 --- /dev/null +++ b/packages/client/ui-command/tests/popup.spec.ts @@ -0,0 +1,356 @@ +/** + * PopupSelectController behavior (design §10.2/§10.3): one options load per + * open with local search filtering, filtered highlight movement, + * single-flight select with open-time context, consume-on-success (CAS miss + * benign), failure-keeps-open retry semantics for both options and onSelect, + * and binding-identity revocation of late settlements after + * dismiss/reopen/dispose. + */ +import { describe, expect, it, vi } from 'vitest' +import type { SelectOption } from '../src/client/contract.ts' +import type { PopupSpec, TokenSegment } from '../src/client/popup.ts' +import { filterOptions, PopupSelectController } from '../src/client/popup.ts' + +interface Ctx { readonly session: string } +const CTX_A: Ctx = { session: 'A' } + +const OPTIONS: SelectOption[] = [ + { id: 'dark', label: 'Dark' }, + { id: 'light', label: 'Light', active: true }, + { id: 'sepia', label: 'Sepia', detail: 'warm' }, +] + +const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } + +function spec(overrides: Partial> = {}): PopupSpec { + return { + options: () => Promise.resolve(OPTIONS), + onSelect: () => undefined, + ...overrides, + } +} + +/** Fake session wiring: records consume/focus calls; consume answer is settable per test. */ +function makeDeps(consumeResult = true) { + const consume = vi.fn((_segment: TokenSegment) => consumeResult) + const focusComposer = vi.fn() + return { consume, focusComposer } +} + +async function readyPopup(overrides: Partial> = {}, deps = makeDeps()) { + const popup = new PopupSelectController(deps) + popup.open('theme', spec(overrides), CTX_A, SEGMENT) + await Promise.resolve() + return { popup, deps } +} + +describe('filterOptions', () => { + it('matches case-insensitively over label and detail; blank keeps all', () => { + expect(filterOptions(OPTIONS, '')).toBe(OPTIONS) + expect(filterOptions(OPTIONS, ' ')).toBe(OPTIONS) + expect(filterOptions(OPTIONS, 'DARK')).toEqual([OPTIONS[0]]) + expect(filterOptions(OPTIONS, 'warm')).toEqual([OPTIONS[2]]) + expect(filterOptions(OPTIONS, 'nope')).toEqual([]) + }) +}) + +describe('open and options load', () => { + it('publishes pending immediately, ready when options land', async () => { + const popup = new PopupSelectController(makeDeps()) + let release!: (options: readonly SelectOption[]) => void + popup.open('theme', spec({ options: () => new Promise((resolve) => { release = resolve }) }), CTX_A, SEGMENT) + expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme', status: 'pending', search: '', submitting: false, error: null }) + release(OPTIONS) + await Promise.resolve() + expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, active: 0 }) + }) + + it('loads options exactly once: search filters locally without re-querying the provider', async () => { + const options = vi.fn(() => Promise.resolve(OPTIONS)) + const { popup } = await readyPopup({ options }) + popup.setSearch('li') + popup.setSearch('light') + const s = popup.state.getSnapshot() + expect(options).toHaveBeenCalledTimes(1) + expect(s.options).toEqual(OPTIONS) // original array retained; filtering is view-side + expect(s.search).toBe('light') + expect(filterOptions(s.options, s.search)).toEqual([OPTIONS[1]]) + }) + + it('a reopen aborts the old load and drops its late arrival', async () => { + const popup = new PopupSelectController(makeDeps()) + let firstSignal!: AbortSignal + let releaseFirst!: (options: readonly SelectOption[]) => void + popup.open('alpha', spec({ + options: (_ctx, signal) => { + firstSignal = signal + return new Promise((resolve) => { releaseFirst = resolve }) + }, + }), CTX_A, SEGMENT) + popup.open('beta', spec(), CTX_A, SEGMENT) + expect(firstSignal.aborted).toBe(true) + releaseFirst([{ id: 'stale', label: 'stale' }]) + await Promise.resolve() + const s = popup.state.getSnapshot() + expect(s.command).toBe('beta') + expect(s.options).toEqual(OPTIONS) + }) + + it('dispose aborts the flying load, clears state, and drops the late arrival', async () => { + const popup = new PopupSelectController(makeDeps()) + let signal!: AbortSignal + let release!: (options: readonly SelectOption[]) => void + popup.open('theme', spec({ + options: (_ctx, s) => { + signal = s + return new Promise((resolve) => { release = resolve }) + }, + }), CTX_A, SEGMENT) + popup.dispose() + expect(signal.aborted).toBe(true) + expect(popup.state.getSnapshot().open).toBe(false) + release(OPTIONS) + await Promise.resolve() + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('an options failure keeps the shell open with search retained, surfaces the error, and retry reloads', async () => { + let attempts = 0 + const { popup } = await readyPopup({ + options: () => { + attempts += 1 + return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(OPTIONS) + }, + }) + await Promise.resolve() + popup.setSearch('da') + // The failure landed before setSearch (readyPopup awaited); search must survive it and retry. + expect(popup.state.getSnapshot()).toMatchObject({ open: true, status: 'failed', error: 'directory down', search: 'da' }) + popup.retry() + expect(popup.state.getSnapshot()).toMatchObject({ status: 'pending', error: null }) + await Promise.resolve() + expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, search: 'da' }) + expect(attempts).toBe(2) + }) + + it('retry is a no-op unless the options load failed', async () => { + const { popup } = await readyPopup() + popup.retry() + expect(popup.state.getSnapshot().status).toBe('ready') + const closed = new PopupSelectController(makeDeps()) + closed.retry() + expect(closed.state.getSnapshot().open).toBe(false) + }) +}) + +describe('search / move / highlight over the filtered list', () => { + it('setSearch rebases the highlight to 0 and ignores closed shells and identical text', async () => { + const { popup } = await readyPopup() + popup.move(1) + expect(popup.state.getSnapshot().active).toBe(1) + popup.setSearch('s') + expect(popup.state.getSnapshot()).toMatchObject({ search: 's', active: 0 }) + const before = popup.state.getSnapshot() + popup.setSearch('s') + expect(popup.state.getSnapshot()).toBe(before) + const closed = new PopupSelectController(makeDeps()) + closed.setSearch('x') + expect(closed.state.getSnapshot().search).toBe('') + }) + + it('move wraps across the FILTERED rows', async () => { + const { popup } = await readyPopup() + popup.setSearch('a') // Dark, Sepia (detail 'warm' also matches 'a'? label match: Dark, Sepia) + const rows = filterOptions(popup.state.getSnapshot().options, 'a') + expect(rows.length).toBe(2) + popup.move(1) + expect(popup.state.getSnapshot().active).toBe(1) + popup.move(1) + expect(popup.state.getSnapshot().active).toBe(0) + popup.move(-1) + expect(popup.state.getSnapshot().active).toBe(1) + }) + + it('move is a no-op while pending, closed, or when the filter matches nothing', async () => { + const pending = new PopupSelectController(makeDeps()) + pending.open('theme', spec({ options: () => new Promise(() => {}) }), CTX_A, SEGMENT) + pending.move(1) + expect(pending.state.getSnapshot().active).toBe(0) + const closed = new PopupSelectController(makeDeps()) + closed.move(1) + expect(closed.state.getSnapshot().active).toBe(0) + const { popup } = await readyPopup() + popup.setSearch('nope') + popup.move(1) + expect(popup.state.getSnapshot().active).toBe(0) + }) + + it('highlight sets the active filtered row and ignores out-of-range or same-index calls', async () => { + const { popup } = await readyPopup() + popup.highlight(1) + expect(popup.state.getSnapshot().active).toBe(1) + popup.highlight(99) + popup.highlight(-1) + popup.highlight(1) + expect(popup.state.getSnapshot().active).toBe(1) + popup.setSearch('dark') // one filtered row → index 1 now out of range + popup.highlight(1) + expect(popup.state.getSnapshot().active).toBe(0) + }) +}) + +describe('select', () => { + it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => { + const seen: Array<{ option: SelectOption; context: Ctx }> = [] + const deps = makeDeps() + const { popup } = await readyPopup({ + onSelect: (option, context) => { seen.push({ option, context }) }, + }, deps) + popup.setSearch('light') + await popup.select(0) + expect(seen).toEqual([{ option: OPTIONS[1], context: CTX_A }]) + expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(deps.focusComposer).toHaveBeenCalledTimes(1) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('is single-flight: the first call enters submitting, later Enter/click calls no-op', async () => { + let release!: () => void + const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve })) + const deps = makeDeps() + const { popup } = await readyPopup({ onSelect }, deps) + const first = popup.select(0) + expect(popup.state.getSnapshot().submitting).toBe(true) + await popup.select(0) + await popup.select(1) + popup.setSearch('x') // locked while submitting + popup.move(1) + popup.highlight(1) + expect(popup.state.getSnapshot()).toMatchObject({ search: '', active: 0 }) + release() + await first + expect(onSelect).toHaveBeenCalledTimes(1) + expect(deps.consume).toHaveBeenCalledTimes(1) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('a consume CAS miss is benign: no retry, still closes and refocuses', async () => { + const deps = makeDeps(false) + const { popup } = await readyPopup({}, deps) + await popup.select(0) + expect(deps.consume).toHaveBeenCalledTimes(1) + expect(deps.focusComposer).toHaveBeenCalledTimes(1) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('an onSelect failure keeps the shell open with search/highlight/token intact, no consumption, and select re-arms', async () => { + let attempts = 0 + const deps = makeDeps() + const { popup } = await readyPopup({ + onSelect: () => { + attempts += 1 + if (attempts === 1) throw new Error('host rejected') + return undefined + }, + }, deps) + popup.setSearch('a') + popup.move(1) + await popup.select(1) + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, status: 'ready', submitting: false, error: 'host rejected', search: 'a', active: 1, + }) + expect(deps.consume).not.toHaveBeenCalled() + await popup.select(1) // retry = selecting again + expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('ignores selects while closed, pending, failed, or out of filtered range', async () => { + const closed = new PopupSelectController(makeDeps()) + await closed.select(0) + expect(closed.state.getSnapshot().open).toBe(false) + const failedDeps = makeDeps() + const { popup: failed } = await readyPopup({ options: () => Promise.reject(new Error('x')) }, failedDeps) + await failed.select(0) + expect(failedDeps.consume).not.toHaveBeenCalled() + const deps = makeDeps() + const { popup } = await readyPopup({}, deps) + popup.setSearch('dark') + await popup.select(1) // only one filtered row + expect(deps.consume).not.toHaveBeenCalled() + expect(popup.state.getSnapshot().open).toBe(true) + }) + + it('a dismiss racing a succeeding onSelect revokes it: no consume, no focus, state stays closed', async () => { + let release!: () => void + const deps = makeDeps() + const { popup } = await readyPopup({ + onSelect: () => new Promise((resolve) => { release = resolve }), + }, deps) + const selecting = popup.select(0) + popup.dismiss() + release() + await selecting + expect(deps.consume).not.toHaveBeenCalled() + expect(deps.focusComposer).not.toHaveBeenCalled() + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('a dispose racing a failing onSelect revokes its error write', async () => { + let reject!: (error: Error) => void + const deps = makeDeps() + const { popup } = await readyPopup({ + onSelect: () => new Promise((_resolve, rej) => { reject = rej }), + }, deps) + const selecting = popup.select(0) + popup.dispose() + reject(new Error('late')) + await selecting + expect(popup.state.getSnapshot()).toMatchObject({ open: false, error: null }) + expect(deps.consume).not.toHaveBeenCalled() + }) + + it('a reopen racing a succeeding onSelect keeps the new shell: no consume of the old segment', async () => { + let release!: () => void + const deps = makeDeps() + const { popup } = await readyPopup({ + onSelect: () => new Promise((resolve) => { release = resolve }), + }, deps) + const selecting = popup.select(0) + popup.open('other', spec(), CTX_A, { via: 'enter', token: '/other' }) + release() + await selecting + await Promise.resolve() + expect(deps.consume).not.toHaveBeenCalled() + expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'other' }) + }) +}) + +describe('dismiss / dispose', () => { + it('dismiss closes, aborts the flying fetch, and is a no-op when already closed', async () => { + const deps = makeDeps() + const popup = new PopupSelectController(deps) + let signal!: AbortSignal + popup.open('theme', spec({ + options: (_ctx, s) => { + signal = s + return new Promise(() => {}) + }, + }), CTX_A, SEGMENT) + popup.dismiss() + expect(signal.aborted).toBe(true) + expect(popup.state.getSnapshot().open).toBe(false) + expect(deps.focusComposer).not.toHaveBeenCalled() // outside-pointer path: the click's target takes focus + popup.dismiss() + popup.dispose() + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('the Escape path restores composer focus explicitly', async () => { + const deps = makeDeps() + const { popup } = await readyPopup({}, deps) + popup.dismiss({ focusComposer: true }) + expect(deps.focusComposer).toHaveBeenCalledTimes(1) + expect(popup.state.getSnapshot().open).toBe(false) + }) +}) diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts new file mode 100644 index 0000000000..0cf94e2f82 --- /dev/null +++ b/packages/client/ui-command/tests/service.spec.ts @@ -0,0 +1,548 @@ +/** + * CommandService tests on a real cordis Context with fake slash/connection + * faces and real session scopes (createScope): session-keyed candidate + * synthesis (host catalog by sessionId + contributions by availability, + * collision fail-loud), the dispatch decision table cell by cell, matchSpace + * hot-key policy, matchEnter strong-wait / reject, the sessionId execute + * payload, the scoped consume-token dispatch, per-session popupFor + * lifecycle, and the directory invalidation event subscriptions. + */ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts' +import type { CommandDescriptor } from '../src/client/directory.ts' +import { CommandService } from '../src/client/service.ts' + +const sid = (k: string): SessionId => k as SessionId + +/** The agent-backed session projection (single state; identity only). */ +const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) }) + +const S1_CMDS: CommandDescriptor[] = [ + { name: 'plan', description: 'bare kind' }, + { name: 'goal', description: 'leadingInput kind', input: { hint: 'goal text' } }, +] + +const S2_CMDS: CommandDescriptor[] = [ + ...S1_CMDS, + { name: 'attach', description: 'scoped shadow', input: { hint: 'path' } }, +] + +type ExecuteValue = { matched: boolean; result?: { kind: 'success' | 'error'; text?: string } } + +interface BenchOptions { + /** Scripted catalog per list payload; default serves the fixed catalogs by session. */ + commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }> + execute?: (payload: { sessionId: SessionId; line: string }) => Promise +} + +async function bench(opts: BenchOptions = {}) { + const ctx = new Context() + const registered = new Map() + const listCalls: Array<{ sessionId: SessionId }> = [] + const executeCalls: Array<{ sessionId: SessionId; line: string }> = [] + const api = { + commands: { + list: async (payload: { sessionId: SessionId }) => { + listCalls.push(payload) + const value = await (opts.commands ?? (p => Promise.resolve({ + commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS, + })))(payload) + return { result: { ok: true as const, value } } + }, + execute: async (payload: { sessionId: SessionId; line: string }) => { + executeCalls.push(payload) + const value = await (opts.execute ?? (() => Promise.resolve({ matched: true })))(payload) + return { result: { ok: true as const, value } } + }, + }, + } + ctx.provide('slash', { + registerSource(src: SlashSource) { + const key = `${src.trigger} ${src.name}` + registered.set(key, src) + return () => { registered.delete(key) } + }, + }) + // Real scope tags behind a fake sessions face (scope/scopeOf are all the service reads). + const scopes = new Map } }>() + ctx.provide('sessions', { + scope: (id: SessionId) => scopes.get(id)?.ctx, + scopeOf: (c: Context) => scopeOf(c), + }) + ctx.provide('connection', { api }) + /** Notices the fake conversation face collected (runDetached routing). */ + const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = [] + ctx.provide('conversation', { + input: { + for: (actx: Context) => ({ + notify: (level: 'info' | 'error', text: string) => { + notices.push({ scope: scopeOf(actx), level, text }) + }, + }), + }, + }) + const fiber = ctx.plugin(CommandService) + await fiber.await() + const command = ctx.get('command') as CommandService + const source = registered.get('/ command') + if (source === undefined) throw new Error('command source not registered') + const mint = (key: string) => { + const handle = createScope(ctx, sid(key)) + scopes.set(sid(key), handle) + return handle + } + /** Warm one session's catalog through the source's own candidate pull. */ + const warm = async (session: ClientSessionContext) => { + await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal }) + } + return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, registered, notices } +} + +function menuPick(source: SlashSource, name: string, session: ClientSessionContext, end?: number) { + const pick: SlashPick = { + candidate: { name }, + session, + position: 'leading', + via: 'menu', + span: { start: 0, end: end ?? name.length + 1, draftRev: 3 }, + } + return source.onPick(pick) +} + +const themeUi = (over: Partial = {}): CommandUiSpec => ({ + kind: 'popupSelect', + options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]), + onSelect: () => undefined, + ...over, +}) + +const themeContribution = (over: Partial = {}): CommandContribution => ({ + name: 'theme', + description: 'client popup kind', + available: () => true, + ui: themeUi(), + ...over, +}) + +const req = (query: string, position: 'leading' | 'inline' = 'leading') => + ({ query, position, signal: new AbortController().signal }) + +describe('registration', () => { + it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => { + const { registered, source, fiber } = await bench() + expect(typeof source.matchSpace).toBe('function') + expect(typeof source.matchEnter).toBe('function') + expect(typeof source.warm).toBe('function') + expect([...registered.keys()]).toEqual(['/ command']) + await fiber.dispose() + expect(registered.size).toBe(0) + }) + + it('the warm hook prewarms the session key: one pull per session, no duplicate over pending', async () => { + const { source, listCalls } = await bench() + source.warm!(proj('s1')) + expect(listCalls).toEqual([{ sessionId: sid('s1') }]) + source.warm!(proj('s2')) + expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }]) + source.warm!(proj('s1')) // s1 already pending → no duplicate pull + expect(listCalls).toHaveLength(2) + }) +}) + +describe('candidates', () => { + it('pulls the session catalog; prefix filter and hint mapping apply', async () => { + const { source, listCalls } = await bench() + const list = await source.candidates(proj('s1'), req('g')) + expect(listCalls).toEqual([{ sessionId: sid('s1') }]) + expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }]) + }) + + it('catalogs are per session: another session pulls its own key', async () => { + const { source, listCalls } = await bench() + const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name) + expect(listCalls).toEqual([{ sessionId: sid('s2') }]) + expect(names).toEqual(['plan', 'goal', 'attach']) + }) + + it('hides leadingInput commands at inline position', async () => { + const { source } = await bench() + const names = (await source.candidates(proj('s1'), req('', 'inline'))).map(c => c.name) + expect(names).toEqual(['plan']) + }) + + it('merges available contributions and filters unavailable ones with the per-call projection', async () => { + const { command, source } = await bench() + const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1')) + command.register(themeContribution({ available })) + const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name) + expect(s1Names).toEqual(['plan', 'goal', 'theme']) + expect(available).toHaveBeenLastCalledWith(proj('s1')) + const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name) + expect(s2Names).not.toContain('theme') + }) + + it('contribution rows ride the same query prefix filter', async () => { + const { command, source } = await bench() + command.register(themeContribution()) + const names = (await source.candidates(proj('s1'), req('th'))).map(c => c.name) + expect(names).toEqual(['theme']) + }) + + it('a contribution/host name collision fails loud', async () => { + const { command, source } = await bench() + command.register(themeContribution({ name: 'plan' })) + await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command') + }) +}) + +describe('dispatch (menu column)', () => { + it('contribution → opens the session popup with the open-time projection, no execute', async () => { + const { command, source, mint, warm, executeCalls } = await bench() + const options = vi.fn((_s: ClientSessionContext) => Promise.resolve([{ id: 'dark', label: 'Dark' }])) + command.register(themeContribution({ ui: themeUi({ options }) })) + const scope = mint('s1') + await warm(proj('s1')) + expect(menuPick(source, 'theme', proj('s1'))).toBe('handled') + const popup = command.popupFor(scope.ctx) + expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme' }) + expect(options).toHaveBeenCalledExactlyOnceWith(proj('s1'), expect.any(AbortSignal)) + expect(executeCalls).toEqual([]) + }) + + it('an unavailable contribution falls through to the host catalog', async () => { + const { command, source, mint, warm } = await bench() + command.register(themeContribution({ available: () => false })) + const scope = mint('s1') + await warm(proj('s1')) + expect(menuPick(source, 'theme', proj('s1'))).toBeUndefined() // no host 'theme' either + expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) + }) + + it('host leadingInput → {claim} with token "/name " and hint; claiming never executes', async () => { + const { source, warm, executeCalls } = await bench() + await warm(proj('s1')) + const outcome = menuPick(source, 'goal', proj('s1')) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + expect(outcome.claim.token).toBe('/goal ') + expect(outcome.claim.hint).toBe('goal text') + expect(executeCalls).toEqual([]) + }) + + it('host bare → consume-token span guard on the session scope + detached execute', async () => { + const { source, mint, warm, executeCalls } = await bench() + const scope = mint('s1') + const consumes: ConsumeTokenRequest[] = [] + scope.ctx.on('slash/input-consume-token', (r) => { + consumes.push(r) + return true + }) + await warm(proj('s1')) + expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled') + expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }]) + await Promise.resolve() + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + }) + + it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => { + const { source, warm } = await bench() + await warm(proj('s1')) + expect(menuPick(source, 'gone', proj('s1'))).toBeUndefined() + }) +}) + +describe('matchSpace (space column)', () => { + it('answers undefined from a not-ready key (no waiting, no RPC)', async () => { + const { source, listCalls } = await bench() + expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() + expect(listCalls).toEqual([]) + }) + + it('hot leadingInput exact token → {claim}; the key axis is the session', async () => { + const { source, warm } = await bench() + await warm(proj('s2')) + const outcome = source.matchSpace!(proj('s2'), '/attach') + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + expect(outcome.claim.token).toBe('/attach ') + // s1's key is still cold: the same token answers undefined there. + expect(source.matchSpace!(proj('s1'), '/attach')).toBeUndefined() + }) + + it('bare kind and contribution names stay plain text', async () => { + const { command, source, warm } = await bench() + command.register(themeContribution()) + await warm(proj('s1')) + expect(source.matchSpace!(proj('s1'), '/plan')).toBeUndefined() + expect(source.matchSpace!(proj('s1'), '/theme')).toBeUndefined() + }) + + it('unknown token / non-slash token → undefined', async () => { + const { source, warm } = await bench() + await warm(proj('s1')) + expect(source.matchSpace!(proj('s1'), '/nope')).toBeUndefined() + expect(source.matchSpace!(proj('s1'), 'plan')).toBeUndefined() + }) +}) + +describe('matchEnter (enter column)', () => { + const signal = () => new AbortController().signal + + it('strong-waits a cold key before adjudicating', async () => { + let release!: (value: { commands: CommandDescriptor[] }) => void + const { source } = await bench({ + commands: () => new Promise((resolve) => { release = resolve }), + }) + const wait = source.matchEnter!(proj('s1'), '/goal args', signal()) + release({ commands: S1_CMDS }) + const outcome = await wait + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + expect(outcome.claim.token).toBe('/goal ') + }) + + it('rejects when warmup fails (never a silent downgrade)', async () => { + const { source } = await bench({ + commands: () => Promise.reject(new Error('warmup boom')), + }) + await expect(source.matchEnter!(proj('s1'), '/goal', signal())).rejects.toThrow('warmup boom') + }) + + it('leadingInput claims args-tolerant (bare and with trailing text)', async () => { + const { source, warm } = await bench() + await warm(proj('s1')) + for (const line of ['/goal', '/goal refactor the loop']) { + const outcome = await source.matchEnter!(proj('s1'), line, signal()) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + expect(outcome.claim.token).toBe('/goal ') + } + }) + + it('bare host command executes detached with the bare-token consume guard', async () => { + const { source, mint, warm, executeCalls } = await bench() + const scope = mint('s1') + const consumes: ConsumeTokenRequest[] = [] + scope.ctx.on('slash/input-consume-token', (r) => { + consumes.push(r) + return true + }) + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/plan', signal())).resolves.toBe('handled') + expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }]) + await Promise.resolve() + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + }) + + it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => { + const { source, warm, executeCalls } = await bench() + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/plan now', signal())).resolves.toBeUndefined() + expect(executeCalls).toEqual([]) + }) + + it('contribution: bare token opens the popup without touching the directory; args → undefined', async () => { + const { command, source, mint, listCalls } = await bench() + command.register(themeContribution()) + const scope = mint('s1') + await expect(source.matchEnter!(proj('s1'), '/theme', signal())).resolves.toBe('handled') + expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true) + expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady + await expect(source.matchEnter!(proj('s1'), '/theme dark', signal())).resolves.toBeUndefined() + }) + + it('unknown name, bare "/", and non-slash lines → undefined', async () => { + const { source, warm } = await bench() + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/nope', signal())).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/', signal())).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), 'plain text', signal())).resolves.toBeUndefined() + }) +}) + +describe('execute payload', () => { + it('claim.submit addresses the session and maps the detached result', async () => { + const { source, warm, executeCalls } = await bench({ + execute: () => Promise.resolve({ matched: true, result: { kind: 'success', text: 'goal set' } }), + }) + await warm(proj('s1')) + const outcome = source.matchSpace!(proj('s1'), '/goal') + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + const settled = await outcome.claim.submit('ship it', new Context()) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }]) + expect(settled).toEqual({ kind: 'success', text: 'goal set' }) + }) + + it('maps matched:false to an error outcome and a matched bare result to success', async () => { + const claimOf = async (opts: BenchOptions) => { + const b = await bench(opts) + await b.warm(proj('s1')) + const outcome = b.source.matchSpace!(proj('s1'), '/goal') + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + return outcome.claim + } + const first = await claimOf({ execute: () => Promise.resolve({ matched: false }) }) + const bad = await first.submit('x', new Context()) + expect(bad.kind).toBe('error') + const second = await claimOf({ execute: () => Promise.resolve({ matched: true }) }) + await expect(second.submit('', new Context())).resolves.toEqual({ kind: 'success' }) + }) +}) + +describe('detached result notices', () => { + const flush = () => new Promise(resolve => setTimeout(resolve, 0)) + + it('success text → info; error result → error; rejection → error, all on the triggering session', async () => { + let mode: 'info' | 'error' | 'reject' = 'info' + const { source, mint, warm, notices } = await bench({ + execute: () => { + if (mode === 'reject') return Promise.reject(new Error('network down')) + return Promise.resolve({ + matched: true, + result: mode === 'info' + ? { kind: 'success' as const, text: 'compacted 12 messages' } + : { kind: 'error' as const, text: 'plan mode refused' }, + }) + }, + }) + mint('s1') + await warm(proj('s1')) + menuPick(source, 'plan', proj('s1')) + await flush() + expect(notices).toEqual([{ scope: sid('s1'), level: 'info', text: 'compacted 12 messages' }]) + + notices.length = 0 + mode = 'error' + await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal) + await flush() + expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'plan mode refused' }]) + + notices.length = 0 + mode = 'reject' + menuPick(source, 'plan', proj('s1')) + await flush() + expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }]) + }) + + it('success without text stays silent; a torn-down scope drops the notice', async () => { + const { source, warm, notices } = await bench({ + execute: () => Promise.resolve({ matched: true, result: { kind: 'success' as const, text: 'orphan' } }), + }) + await warm(proj('ghost')) // never minted: scopeFor misses + menuPick(source, 'plan', proj('ghost')) + await flush() + expect(notices).toEqual([]) + }) +}) + +describe('register (contribution face)', () => { + it('duplicate registration throws; the disposer frees the name', async () => { + const { command } = await bench() + const dispose = command.register(themeContribution()) + expect(() => command.register(themeContribution())).toThrow('duplicate contribution') + dispose() + command.register(themeContribution())() + }) +}) + +describe('popupFor', () => { + it('resolves lazily per session; a foreign session gets its own controller; unscoped ctx throws', async () => { + const { ctx, command, mint } = await bench() + const a = mint('s1') + const first = command.popupFor(a.ctx) + expect(command.popupFor(a.ctx)).toBe(first) + expect(command.popupFor(mint('s2').ctx)).not.toBe(first) + expect(() => command.popupFor(ctx)).toThrow('requires a session scope') + }) + + it('a successful select dispatches the scoped consume-token and fires the bound composer focus', async () => { + const { command, source, mint } = await bench() + const onSelect = vi.fn() + command.register(themeContribution({ ui: themeUi({ onSelect }) })) + const scope = mint('s1') + const consumes: ConsumeTokenRequest[] = [] + scope.ctx.on('slash/input-consume-token', (r) => { + consumes.push(r) + return true + }) + const focus = vi.fn() + command.bindComposerFocus(sid('s1'), focus) + + expect(menuPick(source, 'theme', proj('s1'), 6)).toBe('handled') + const popup = command.popupFor(scope.ctx) + await Promise.resolve() // options land + await popup.select(0) + expect(onSelect).toHaveBeenCalledExactlyOnceWith({ id: 'dark', label: 'Dark' } satisfies SelectOption, proj('s1')) + expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 6, draftRev: 3 } } }]) + expect(focus).toHaveBeenCalledTimes(1) + }) + + it('the enter path opens with the bare-token guard', async () => { + const { command, source, mint } = await bench() + command.register(themeContribution()) + const scope = mint('s1') + const consumes: ConsumeTokenRequest[] = [] + scope.ctx.on('slash/input-consume-token', (r) => { + consumes.push(r) + return true + }) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal) + const popup = command.popupFor(scope.ctx) + await Promise.resolve() + await popup.select(0) + expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/theme' } }]) + }) + + it('the scope disposer disposes the controller and a re-mint resolves fresh', async () => { + const { command, source, mint } = await bench() + command.register(themeContribution()) + const scope = mint('s1') + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal) + const popup = command.popupFor(scope.ctx) + expect(popup.state.getSnapshot().open).toBe(true) + + await scope.fiber.dispose() + expect(popup.state.getSnapshot().open).toBe(false) + expect(command.popupFor(mint('s1').ctx)).not.toBe(popup) + }) +}) + +describe('directory invalidation events', () => { + it('commands/changed repulls in the background while the old snapshot serves', async () => { + let round = 0 + const { ctx, source, warm } = await bench({ + commands: () => { + round += 1 + return Promise.resolve({ + commands: round === 1 + ? S1_CMDS + : [{ name: 'fresh', description: '', input: { hint: 'h' } }], + }) + }, + }) + await warm(proj('s1')) + ctx.emit('commands/changed') + await new Promise(resolve => setTimeout(resolve, 0)) + expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() + expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() + }) + + it('connection/reset hard-drops every session key until its rewarm lands', async () => { + let block = false + let release!: (value: { commands: CommandDescriptor[] }) => void + const { ctx, source, warm } = await bench({ + commands: () => (block + ? new Promise((resolve) => { release = resolve }) + : Promise.resolve({ commands: S2_CMDS })), + }) + await warm(proj('s2')) + expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined() + block = true + ctx.emit('connection/reset') + // Hard reset: silent until the rewarm lands. + expect(source.matchSpace!(proj('s2'), '/attach')).toBeUndefined() + release({ commands: S2_CMDS }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined() + }) +}) diff --git a/packages/client/ui-command/tsconfig.json b/packages/client/ui-command/tsconfig.json new file mode 100644 index 0000000000..b95692eda1 --- /dev/null +++ b/packages/client/ui-command/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../connection" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slash" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-command/tsdown.config.ts b/packages/client/ui-command/tsdown.config.ts new file mode 100644 index 0000000000..5ab0fc4fda --- /dev/null +++ b/packages/client/ui-command/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-command', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 8117d42ea4..56923eff77 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467 -README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311 +# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md +README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6 +README.zh.md: 88992176165ab11050a30c7df381479796908ba2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b9ec555f15..453922dafd 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. @@ -12,6 +12,8 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', 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); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. + Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). @@ -31,3 +33,4 @@ None; this package neither assembles nor sends a provider request. - **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project. +- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b1e3c1f433..8899217616 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 +会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 无会话主视觉区会渲染来自 Session 列表投影的前端 Session Intent;没有真实 Workspace 时,还会包含其前端 Workspace Intent。它声明 `conversation.empty.workspace`,ui-workspace 会在此注册侧边栏所用的同一选择器。WorkspacesService 启动跨对象流程;每个 Workspace 或 Session 对象拥有自身的物化。Session 在发布期间保持身份,并保留任何仍需连接或交付的提示词;ConversationRoot 读取该 `pendingPrompt`,其来源是 `useSession`,再通过 scope 内的 ConversationService 编辑或重试。 @@ -12,6 +12,8 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 + 逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 @@ -31,3 +33,4 @@ - **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。 +- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 95fde30b5a..81e5fe265a 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -41,6 +41,7 @@ "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index e14f110b7c..7a229758b1 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -5,15 +5,20 @@ import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ViewTab } from './contract/views.ts' import type { - ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected, + ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected, } from './contract/slots.ts' import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' +import { InputHub } from './input/hub.ts' +import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { todoToolview } from './toolviews/todo-row.tsx' +import { todoDockEntry } from './skeleton/TodoPanel.tsx' +import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' +import { ConversationSession } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' -import { EmptyState } from './skeleton/EmptyState.tsx' /** Services required by the conversation plugin. */ export const inject = ['slots', 'layout', 'sessions', 'workspaces'] @@ -49,50 +54,100 @@ export function apply(ctx: Context): void { return tabs } - // Conversation occupant. Declaring the view ring here is claiming it: - // ConversationRoot is the only component authorized to render the ring. + // The per-session input machine registry (InputService face; published as + // ctx.conversation.input by the service below sharing this one instance). + const inputHub = new InputHub(ctx) + + // Decision 19/20: the input machine feeds every session-scope slot + // component through the standard provide channel — the 'input' hook plus + // the two public actions. Materialization is the shell creation trigger + // (per-session lazy; scope disposer tears down). + ctx.effect(() => sessions.provide({ + hooks: ['input'], + props: ['inputActions'], + resolve: (binding) => { + const shell = inputHub.shellFor(binding) + return { + hooks: { input: shell.state }, + props: { inputActions: shell.actions }, + } + }, + }), 'ui-conversation: input standard-kit provider') + + // Resident current-session-optional shell. It owns the stable Hero/composer + // frame while strict session slots fill only their session-bound regions. slots.register({ name: 'conversation', - // The composer chain rides the same declaration table: takeover plugins - // register selector-routed replacements of the InputBar. children: { - 'conversation.view': { kind: 'list', scope: 'session' }, + 'conversation.session': { kind: 'single', scope: 'session' }, 'conversation.composer': { kind: 'chain', scope: 'session' }, + 'conversation.composer.bar': { kind: 'single', scope: 'session' }, + 'conversation.input.overlay': { kind: 'list', scope: 'session' }, + 'conversation.input.dock': { kind: 'list', scope: 'session' }, + 'conversation.composer.dock': { kind: 'list', scope: 'session' }, + 'conversation.input.left': { kind: 'list', scope: 'session' }, + 'conversation.input.right': { kind: 'list', scope: 'session' }, + 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, }, + inject: (sessionId: SessionId | undefined): ConversationInjected => ({ + selectWorkspace: (workspaceId) => { + void workspaces.connectWorkspace(workspaceId).then((nextId) => { + if (sessionId !== undefined && nextId !== sessionId) { + const from = inputHub.shell(sessionId) + const draft = from.snapshot.draft + if (draft !== '') { + inputHub.shell(nextId).setDraft(draft) + from.setDraft('') + } + } + sessions.open(nextId) + }).catch(() => { + // Failure leaves the current Hero state available to retry. + }) + }, + }), + }, ConversationRoot) + + // The strict session subtree owns only per-session store and view content; + // the resident parent keeps Hero and composer layout identity stable. + slots.register({ + name: 'conversation.session', + children: { 'conversation.view': { kind: 'list', scope: 'session' } }, store: chatStore, - inject: (sessionId: SessionId, actions: BoundActions): ConversationInjected => { - // History pull is NOT triggered here: the runtime sessions service opens - // the event window when the watch lands on the session (cell/binding - // resolution) — an inject factory assembles callbacks, it has no side - // effect on session state. - const scoped = scopedConversation(sessions, sessionId) + inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => ({ + views: { + list: viewTabs, + subscribe: fn => slots.subscribe('conversation.view', fn), + version: () => slots.getVersion('conversation.view'), + }, + bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write), + open: (id) => { sessions.open(id) }, + }), + }, ConversationSession) + + // The default composer body: its own single slot inside the composer + // chain's fallback (decision 20). Public machine surface arrives via the + // provide channel above; the keyboard command face and the stop/retry + // verbs ride this inject (package-internal — hub and bar are one plugin). + slots.register({ + name: 'conversation.composer.bar', + // The two named control seats in the bar's tool row (plan left, model + // right); empty until their owning plugins register (B ruling). + children: { + 'conversation.input.plan': { kind: 'single', scope: 'session' }, + 'conversation.input.model': { kind: 'single', scope: 'session' }, + }, + inject: (sessionId: SessionId): ComposerBarInjected => { return { - views: { - list: viewTabs, - subscribe: fn => slots.subscribe('conversation.view', fn), - version: () => slots.getVersion('conversation.view'), - }, - send: (text, mode) => { - const trimmed = text.trim() - if (trimmed === '') return - // Optimistic clear with failure restore (choreography lives with the - // sender; the business failure also lands in snapshot.promptError). - // The store write path stays inside the declared actions set: - // restoreDraft itself no-ops once the user typed something new. - actions.clearDraft() - void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) }) - }, + keyboard: inputHub.keyboard(sessionId), stop: () => { - scoped.cancel().catch(() => { + scopedConversation(sessions, sessionId).cancel().catch(() => { // Stop failure surfaces via snapshot.promptError; nothing to restore. }) }, - open: (sessionId) => { sessions.open(sessionId) }, - updateSessionPrompt: (text) => { scoped.updatePendingPrompt(text) }, - retrySessionPrompt: () => { scoped.retryPendingPrompt() }, } }, - }, ConversationRoot) + }, InputBar) // The chat view: first entry of the ring this package just declared. // Declaring the keyed toolview hole here is claiming it: ChatView is the @@ -124,11 +179,22 @@ export function apply(ctx: Context): void { // toolview registrants using `inject: ['conversation']` as their load-order // seam: the service being present implies the chat entry (and with it the // 'conversation.chat.toolview' declaration) is on the ledger. - ctx.plugin(ConversationService) + ctx.plugin(ConversationService, { input: inputHub }) - // The bash sample rides that exact seam, in third-party posture. + // The bash sample rides that exact seam, in third-party posture + // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions). ctx.plugin(bashToolviewSample) + // The todo_write row rides the same seam (a product registration, not a sample). + ctx.plugin(todoToolview) + + // The plan strip rides the input dock above the queue rows (same posture). + ctx.plugin(todoDockEntry) + + // The read-only queue dock entry (T9 file territory) rides the same + // registration seam into the input dock declared above. + ctx.plugin(queueDockEntry) + slots.register({ name: 'details', store: chatStore, @@ -137,13 +203,4 @@ export function apply(ctx: Context): void { }), }, DetailsPanel) - slots.register({ - name: 'conversation.empty', - children: { 'conversation.empty.workspace': { kind: 'single', scope: 'root' } }, - inject: (): EmptyStateInjected => ({ - startSession: (workspaceId, prompt) => { workspaces.startSession(workspaceId, prompt) }, - updateSessionPrompt: (text) => { sessions.updateIntent(text) }, - sendSession: () => { workspaces.sendSession() }, - }), - }, EmptyState) } diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index d548f2d7be..6d75f9a519 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -37,17 +37,11 @@ border-radius: 6px; } -/* Selection linkage: the selected call row wears the blue outline. - button-info-fill flips 500→400 with the theme, hitting the darker-blue - dark-mode spec exactly (business-primary stays 500 on both). */ -.callRow[data-selected] { - outline: 1.5px solid var(--dsw-alias-button-info-fill); - outline-offset: 1px; -} +/* Selection still sets data-selected for details linkage; no outline — + tool rows match Think chrome (no selected ring). */ /* run_code sub-dispatch rows: indented under the parent row, left-edged so - the code turn reads as one unit; each nested row is itself a .callRow - (same components, same selection outline as top-level rows). */ + the code turn reads as one unit; each nested row is itself a .callRow. */ .subCalls { display: flex; flex-direction: column; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 50e560278d..22537f9cfe 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -1,10 +1,11 @@ -/* User bubble: right-aligned, figma r22 fill = the bubble specific token - (#EDF3FE light / dark pair rides the token sheet). */ +/* User bubble: right-aligned column (bubble + IconActions). Figma + User_Bubble/message_container 659:38813 — r22 fill, actions gap 6 below. */ -/* Block spacing is the flow column's gap alone — no extra padding here. */ .userRow { display: flex; - justify-content: flex-end; + flex-direction: column; + align-items: flex-end; + gap: 6px; } .bubble { @@ -19,6 +20,46 @@ color: var(--dsw-alias-label-primary); } +.actions { + display: flex; + align-items: center; + gap: 10px; + height: 28px; +} + +/* Hover-capable pointers: hide until the row is hovered/focused. Touch / + hover:none keeps actions visible (opacity:0 still hit-tests). */ +@media (hover: hover) { + .actions { + opacity: 0; + transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); + } + + .userRow:hover .actions, + .userRow:focus-within .actions { + opacity: 1; + } +} + +.action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 6px; + border: none; + border-radius: 28px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.action:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + .badge { display: inline-block; margin-bottom: 4px; @@ -32,3 +73,18 @@ .contextRow { padding: 2px 0; } + +/* Reference chip projection inside a user bubble (`name` model + spans render as chips; free geometry — no textarea pairing here). */ +.refChip { + display: inline-block; + margin: 0 2px; + padding: 0 8px; + border-radius: 6px; + background: rgba(97, 135, 216, 0.22); + color: var(--dsw-alias-label-primary); + font-size: 0.85em; + line-height: 1.6; + white-space: nowrap; + vertical-align: baseline; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 4bfe07d687..4ecfdadf88 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,13 +1,18 @@ -// MessageItem: the four simple node kinds — user bubble (right-aligned), -// steering (badged bubble), context injection and unknown-surface JSON rows. -// Props are frozen node slices off the snapshot cache; memo holds across -// streaming because unchanged nodes keep their references. +// MessageItem: the four simple node kinds — user bubble (right-aligned, with +// copy / branch / edit IconActions), steering (badged bubble), context +// injection and unknown-surface JSON rows. Props are frozen node slices off +// the snapshot cache; memo holds across streaming because unchanged nodes +// keep their references. -import { memo } from 'react' +import { memo, useCallback } from 'react' +import type { ReactNode } from 'react' import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconBranchOutline16, IconCopyOutline16, IconEditOutline16, + JsonBlock, MessageText, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' import css from './MessageItem.module.css' export interface MessageItemProps { @@ -25,16 +30,114 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown return { text: texts.join(''), rest } } +/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */ +async function writeClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + } catch { + // Denied permissions / iframe policy. + } + return + } + const exec = typeof document.execCommand === 'function' + ? document.execCommand.bind(document) + : undefined + if (exec === undefined) return + const el = document.createElement('textarea') + el.value = text + el.setAttribute('readonly', '') + el.style.position = 'fixed' + el.style.left = '-9999px' + document.body.appendChild(el) + el.select() + try { + exec('copy') + } catch { + // Clipboard unavailable; the button stays idle. + } + el.remove() +} + +/** + * Display projection of reference forms in a user bubble (free geometry — no + * textarea alignment constraint here); everything else stays plain text. The + * logged model text remains the single truth; this is presentation only. Two + * shapes decorate: legacy `name` spans (pre-decision-21 + * history) and plain-text `/name` / `@name` word-boundary tokens (decision + * 21: the sent text IS the reference — the bubble uses the same plainest + * token scan as the composer, minus the lexicon: sent tokens were validated + * at compose time, so shape alone decorates). + */ +function projectUserText(text: string): ReactNode { + const re = /([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g + const parts: ReactNode[] = [] + let cursor = 0 + let m: RegExpExecArray | null + while ((m = re.exec(text)) !== null) { + const legacy = m[1] !== undefined + const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0) + const label = legacy ? `/${m[1]}` : m[3] ?? '' + if (tokenStart > cursor) parts.push() + parts.push( + + {label} + , + ) + cursor = legacy ? m.index + m[0].length : tokenStart + label.length + } + if (parts.length === 0) return + if (cursor < text.length) parts.push() + return <>{parts} +} + +/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */ +function UserActions({ text }: { text: string }) { + const onCopy = useCallback(() => { + void writeClipboard(text) + }, [text]) + return ( +
    + + + + + + + + + +
    + ) +} + export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { switch (node.kind) { - case 'user': + case 'user': { + const { text, rest } = contentText(node.content) + return ( +
    +
    + {projectUserText(text)} + {rest.map((block, i) => )} +
    + +
    + ) + } case 'steering': { const { text, rest } = contentText(node.content) return (
    - {node.kind === 'steering' && 插话} - + 插话 + {projectUserText(text)} {rest.map((block, i) => )}
    diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 095b57a5f8..1e620f0905 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,12 +1,22 @@ /** Conversation slot declarations and their composed component props. */ -import type { RefObject } from 'react' -import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ReactNode, RefObject } from 'react' +import type { + MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, +} from '@deepseek-ai/dsh-client-ui-slots' +import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** + * Strict-session content inside the resident conversation shell. This + * subtree owns the per-session chat store, header, and view ring and is + * remounted when the current session id changes. + */ + 'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps } /** * The conversation view ring: one list entry per view tab (chat here; * trajectory/waterfall from ui-trajectory), rendered one-at-a-time by @@ -31,9 +41,83 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * zero owner changes. */ 'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps } - /** Shared Workspace picker hole used by the page-local Session Intent hero. */ - 'conversation.empty.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps } + /** + * The hero-phase Workspace picker hole: rendered by ConversationRoot + * while the session is blank (picking another workspace switches to that + * workspace's blank session, draft carried). Root scope: the picker + * reads the global workspace list. + */ + 'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps } + // 'conversation.input.overlay' merges in ui-slash (dedup ruling: the + // dependency direction is the hard constraint — ui-slash cannot import + // this package, while this package's input contract already imports + // ui-slash, so the type arrives transitively). The runtime declaration + // (children table in apply.ts) stays here with the other input slots. + /** + * Stacked strip above the input (queue rows / GoalBar / attachments; + * design §6 MIX evidence: entries coexist in fixed order). + */ + 'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone } + /** The composer top-edge band (stats line family). */ + 'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone } + /** Tool-row left region inside the input card (existing chrome stays in place beside entries). */ + 'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone } + /** Tool-row right region inside the input card. */ + 'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone } + /** + * The default composer body: a single slot rendered as the composer + * chain's fallback (decision 20 — a real entry, not a chain rider, so a + * takeover election hides rather than unmounts it and the textarea DOM + * survives). InputBar registers here from this package's apply; its + * machine state arrives through the standard provide channel (useInput + + * inputActions), the keyboard command face through its own inject. + */ + 'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps } + /** + * The Plan-mode control seat in the composer tool row (left group). + * Declared by the composer-bar entry; empty until a plan plugin + * registers (B ruling: no placeholder fallback). + */ + 'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } + /** + * The model-select seat in the composer tool row (right group). Same + * empty-until-registered contract as the plan seat. + */ + 'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } } + + /** + * ui-conversation's members of the session standard kit, provided through + * `sessions.provide` (decision 19/20): every session-scope slot component + * receives the input machine's state hook and the two public actions. + */ + interface SessionStandardProps { + /** Selector hook over the session's live input machine state. */ + useInput: SnapshotSelectorHook + /** The public input action face (stable identity per session). */ + inputActions: InputActions + } + + /** Input members for the resident composer while current session is optional. */ + interface SessionMaybeStandardProps { + useInput: MaybeSnapshotSelectorHook + inputActions: InputActions | undefined + } +} + +/** Owner share of the strict session content seat. */ +export interface ConversationSessionOwnerProps { +} + +/** + * The input-region slot currency (plan §1.4): dock/left/right entries read + * the conversation snapshot and the live input state as owner props (both + * are point-in-time snapshots — the dispatching skeleton re-renders on + * either store's change, so entries stay current without subscribing). + */ +export interface InputZone { + readonly session: ConversationSnapshot + readonly input: InputState } /** @@ -87,24 +171,72 @@ export type ChatStore = ReturnType /** Business callbacks injected into the conversation slot. */ export interface ConversationInjected { + /** + * Connect the selected Workspace and open its reusable/new blank session. + * When a blank session is already current, carry its draft to the target. + */ + selectWorkspace(workspaceId: WorkspaceId): void +} + +/** Business callbacks injected into the strict session content seat. */ +export interface ConversationSessionInjected { /** Views projected from the `conversation.view` slot ledger. */ views: { list(): readonly ViewTab[] subscribe(fn: () => void): () => void version(): number } - /** Send choreography: trims, clears the draft optimistically, restores it on failure. */ - send(text: string, mode: 'queue' | 'steer'): void - /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ - stop(): void + /** Bind the input machine's draft persistence mirror to the session store. */ + bindDraftMirror(write: (text: string) => void): () => void /** Select a real Session through the runtime navigation owner. */ open(sessionId: SessionId): void - /** Update the scoped Session's retained prompt. */ - updateSessionPrompt(text: string): void - /** Retry the scoped Session's retained prompt. */ - retrySessionPrompt(): void } +/** + * Owner share of the composer-bar slot: ConversationRoot's layout-phase + * inputs plus the input-region child-slot content it renders (the region + * slots stay declared/rendered by the conversation entry; the bar hosts the + * results as chrome). + */ +export interface ComposerBarOwnerProps { + /** Hero = empty-state centered card; composer = resident bottom bar. */ + variant: 'hero' | 'composer' + placeholder?: string + /** Optional content rendered above the textarea. */ + accessory?: ReactNode + /** Floating overlay anchor content (menu / popup shell entries), rendered inside the card. */ + overlay?: ReactNode + /** input.left slot entries (tool row, beside the resident chrome). */ + leftItems?: ReactNode + /** input.right slot entries (tool row, before the primary button). */ + rightItems?: ReactNode + onAdd?: () => void + addLabel?: string +} + +/** Injected share of the composer-bar entry (package-internal faces). */ +export interface ComposerBarInjected { + /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */ + keyboard: ComposerKeyboard + /** Cancel the in-flight turn. */ + stop(): void +} + +/** + * Owner share of the two named composer control seats (plan / model): the + * bar passes its disable state; the filling entry owns everything else. + */ +export interface InputControlOwnerProps { + /** Session-removed lock (the bar's chrome disable state). */ + locked: boolean +} + +/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */ +export type ComposerBarProps = + PropsRuntime<'conversation.composer.bar'> + & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> + & ComposerBarInjected + /** * Composer chain currency: what ConversationRoot dispatches at its * renderSlotChain site. The owner declares the currency only — never a @@ -116,10 +248,26 @@ export interface ComposerChainProps { interactions: readonly PendingInteraction[] } -/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */ +/** + * Full conversation-slot component props: runtime & child-render (view ring + * + composer chain/bar + input-region + hero picker slots) & store & injected shares. + */ export type ConversationSlotProps = - PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'> - & PropsStore & ConversationInjected + PropsRuntime<'conversation'> & PropsRenderSlots< + | 'conversation.session' | 'conversation.composer' | 'conversation.composer.bar' + | 'conversation.input.overlay' + | 'conversation.input.dock' | 'conversation.composer.dock' + | 'conversation.input.left' | 'conversation.input.right' + | 'conversation.hero.workspace' + > + & ConversationInjected + +/** Full strict-session content props: per-session store, view ring, and callbacks. */ +export type ConversationSessionSlotProps = + PropsRuntime<'conversation.session'> + & PropsRenderSlots<'conversation.view'> + & PropsStore + & ConversationSessionInjected /** * Injected share of the chat view entry: the two callbacks whose targets live @@ -148,24 +296,10 @@ export interface DetailsInjected { /** Full details-slot component props: selection arrives through the shared store, call material through useSession. */ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & DetailsInjected -/** Owner share common to the empty hero's Workspace picker. */ +/** Owner share common to the hero / New-Session Workspace pickers. */ export interface EmptyWorkspaceOwnerProps { open: boolean anchorRef?: RefObject onPick(workspaceId: WorkspaceId): void onClose(): void } - -/** Runtime-owned actions injected into the empty-state occupant. */ -export interface EmptyStateInjected { - /** Replace the current Session intent, optionally preserving a prompt while retargeting. */ - startSession(workspaceId?: WorkspaceId, prompt?: string): void - /** Update the current Session intent's controlled prompt. */ - updateSessionPrompt(text: string): void - /** Materialize and send the current Session intent. */ - sendSession(): void -} - -/** Full empty-state component props: runtime projections, picker child slot, and injected actions. */ -export type EmptyStateSlotProps = - PropsRuntime<'conversation.empty'> & PropsRenderSlots<'conversation.empty.workspace'> & EmptyStateInjected diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index a48dfdad34..76af2f431c 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -13,9 +13,9 @@ export type { } from './contract/views.ts' export type { ToolCallBlock } from './contract/tool-call-model.ts' export type { - ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected, - ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, - EmptyStateInjected, EmptyStateSlotProps, EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, + ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, + ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, + EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts new file mode 100644 index 0000000000..8a4d2905db --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -0,0 +1,269 @@ +/** + * Frozen input-machine contract (design §9.1, eng. plan §3.9-3.12). Types + * only. Three-tier visibility: business packages see InputState via the + * InputZone currency; the scoped input events carry the mutation verbs; the + * conversation wiring layer alone sees the full SessionInput. InputMachine + * (machine.ts) is package-private and never exported. + */ +import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, + ReferenceInsert, SubmitOutcome, TokenSpan, +} from '@deepseek-ai/dsh-client-ui-slash/client' + +/** + * The scoped-event application verbs: the hub's bail listeners call these, + * and the boolean answer IS the event's bail value (true ⟺ the machine + * accepted after phase and span/bare-token guards). + */ +export interface InputTarget { + /** Replace the trigger span with claim.token and enter claimed (span-CAS'd). */ + beginCommand(claim: CommandClaim, span: TokenSpan): boolean + /** Replace the trigger span with one reference occurrence (span-CAS'd). */ + insertReference(ref: ReferenceInsert, span: TokenSpan): boolean +} + +/** Per-session input facade owned by the conversation wiring layer. */ +export interface SessionInput extends InputTarget { + /** Single write path for draft text (all mutation rides machine events). */ + setDraft(text: string): void + /** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */ + submit(mode?: 'queue' | 'steer'): void + /** + * Surface a notice outside the machine's own effect stream: detached + * command results and business notifications render through here. + * Session-routed — resolving the facade via InputService.for(actx) lands + * the notice on that session's composer, so a result arriving after a + * session switch still reaches its own session. + * @param level - severity tier. + * @param text - notice body. + */ + notify(level: 'info' | 'error', text: string): void + /** Input state store (InputZone currency + decorations read here). */ + readonly state: SnapshotStore +} + +/** Session-addressed access to the per-session input facade. */ +export interface InputService { + /** Resolve the facade for one session-scope ctx. */ + for(actx: ClientContext): SessionInput +} + +/** + * The public input action face provided to every session-scope slot + * component (decision 20): two stable-identity void callbacks, mirroring the + * useStore+actions convention. Command-style handles (track/arbitrate/space/ + * undo/paste/…) stay InputBar-private and never ride this face. + */ +export interface InputActions { + /** Single public draft write path (full next draft; occurrence math via diff scan). */ + setDraft(text: string): void + /** Enter submission (adjudication / claim transaction / default sink inside). */ + submit(mode?: 'queue' | 'steer'): void +} + +/** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */ +export interface InputNotice { + readonly level: 'info' | 'error' + readonly text: string + readonly seq: number +} + +/** + * The InputBar-exclusive keyboard/DOM command face (decision 20): synchronous + * returns and event-handler semantics that must not enter the public provide + * channel. Handed to the composer-bar entry through its own inject — + * package-internal, never across a plugin boundary. The session shell + * satisfies it structurally. + */ +export interface ComposerKeyboard { + /** Latest surfaced notice store (null after none). */ + readonly notices: SnapshotStore + /** Live machine state for event-handler reads (render reads go through useInput). */ + readonly snapshot: InputState + /** Draft write with the DOM-observed edit shape (narrows occurrence math). */ + setDraft(text: string, editRange?: EditRange): void + /** Newline at the selection as a machine transaction (Ctrl+Enter path). */ + newline(selection: EditSelection): void + undo(): void + redo(): void + /** Paste over the selection (sync components ride the same transaction). */ + pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void + /** Caret/selection gestures the machine cannot observe end the paste attempt. */ + invalidatePaste(): void + /** Feed a draft/caret change through trigger detection (guard derived from phase). */ + track(draft: string, caret: number): void + /** Keyboard arbitration while the menu is open ('pass' when no pipeline). */ + arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome + /** Space adjudication; true = the input applied a claim — caller preventDefaults. */ + space(): boolean + /** Dismiss the popupSelect shell (any interaction outside the box). */ + dismissPopup(): void + /** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */ + lexicon(): ReadonlyMap<'/' | '@', readonly string[]> +} + +/** One queued-message row projected from the session/queued frames (T9 supplies the store). */ +export interface QueuedMessage { + /** Stable row key: the enqueueing prompt's rpcId. */ + readonly key: string + readonly preview: string +} + +/** Guard union of the scoped consume-token event, checked by the machine. */ +export type ConsumeTokenGuard = ConsumeTokenRequest['guard'] + +/** Half-open [start, end) range/selection in draft character coordinates. */ +export interface EditSelection { + readonly start: number + readonly end: number +} + +/** + * One edit applied to the previous draft: [start, end) in the PREVIOUS + * draft's coordinates was replaced by insertedLength characters. Supplied by + * the wiring layer when the DOM event exposes the edit shape; absent, the + * machine recovers it with a prefix/suffix common-scan diff. + */ +export interface EditRange extends EditSelection { + readonly insertedLength: number +} + +/** + * One reference chip occurrence, backing exactly one U+FFFC placeholder in + * the draft (design §9.1 底层表示). Identity is occurrenceId — same-named + * references stay independently addressable. label/clipboardText are the + * owner's insert-time projections, cached so the chip survives owner loss + * (invalid flips instead of dropping the occurrence). + */ +export interface Occurrence { + /** Machine-minted stable identity (monotonic per machine). */ + readonly occurrenceId: number + /** Owning source name (serializer routing key). */ + readonly source: string + /** Owner-scoped reference id. */ + readonly ref: string + /** Placeholder offset in the draft; the occurrence occupies exactly [offset, offset+1). */ + readonly offset: number + /** Chip display label (insert-time cache). */ + readonly label: string + /** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */ + readonly clipboardText: string + /** Owner-resolution failure flag: chip renders invalid; serialization must fail. */ + readonly invalid?: boolean +} + +/** One sync-matched paste component; start/end are relative to the pasted text. */ +export interface PasteComponent extends EditSelection { + readonly reference: ReferenceInsert +} + +/** + * Live paste-match attempt published while async matching may still upgrade + * pasted tokens (design §9.1 剪贴板 round-trip). Any non-paste transaction, + * submit start, invalidate-paste, or release ends it; a paste-upgrade keeps + * it current (later tokens re-CAS against the advanced draftRev). + */ +export interface PasteAttemptState { + /** Machine-minted attempt identity (paste-upgrade must match it). */ + readonly attemptId: number + /** Pasted range in the draft as of the paste transaction. */ + readonly insertedRange: EditSelection + /** Caller-supplied projection generation echoed back (the controller drops cross-generation results). */ + readonly generation: number +} + +/** + * InputMachine construction knobs. The machine never reads an ambient clock: + * `now` is the only time source, injected by the shell (tests inject a + * fake). The default clock is constant, i.e. consecutive single-char typing + * always coalesces until a non-typing transaction intervenes. + */ +export interface InputMachineOptions { + /** Single-char typing undo-merge window in ms (default 1000). */ + readonly mergeWindowMs?: number + /** Monotonic clock for typing-merge decisions (default: constant 0). */ + readonly now?: () => number +} + +/** Published input state (the currency; per-session). */ +export interface InputState { + readonly draft: string + /** Monotonic draft revision (span CAS compares against this). */ + readonly draftRev: number + readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' + /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ + readonly claim?: { readonly token: string; readonly hint?: string } + /** Chip occurrence table, sorted by offset (one U+FFFC per entry). */ + readonly occurrences: readonly Occurrence[] + /** Live paste-match attempt (absent when no paste is matchable). */ + readonly paste?: PasteAttemptState + /** Read-only queue projection (session/queued frames + connect snapshot). */ + readonly queue: readonly QueuedMessage[] +} + +/** + * One in-flight submission attempt: the ONLY id concept in the submit plane. + * Created on enter; carried by adjudicated/submit-settled events; stale + * attempts are dropped (anti-backwash). release/session teardown aborts the + * current attempt, keeping the promise bounded. + */ +export interface SubmitAttempt { + readonly seq: number + readonly signal: AbortSignal + /** Draft at enter time; rollback restores it only while the live draft still equals it. */ + readonly draftSnapshot: string +} + +/** + * InputMachine input events (the machine's single write path). Every draft + * mutation is one transaction: draft edit, occurrence reconciliation, and + * undo-log push are atomic inside dispatch(). Events carrying `at` stamp the + * injected clock reading; only single-char typing coalescing reads it. + */ +export type InputEvent = + /** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */ + | { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange } + /** Insert '\n' replacing the selection (F1: the execCommand newline path moved into the machine). */ + | { readonly type: 'newline'; readonly selection: EditSelection } + | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan } + /** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */ + | { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan } + /** Delete a settled command token; success is observable as a draftRev advance. */ + | { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard } + /** Owner-resolution result: exactly the listed occurrences are invalid (style bit; not a transaction). */ + | { readonly type: 'set-invalid'; readonly invalidIds: readonly number[] } + | { readonly type: 'undo' } + | { readonly type: 'redo' } + /** + * Paste text replacing the selection, one transaction. Hot-snapshot sync + * matches ride in as components (chips minted inside the SAME transaction: + * one undo returns to pre-paste); a PasteMatchAttempt opens for the async + * remainder. Component ranges must be disjoint and inside the pasted text. + */ + | { readonly type: 'paste-begin'; readonly text: string; readonly selection: EditSelection; readonly components?: readonly PasteComponent[]; readonly generation?: number } + /** Async match landed: upgrade one pasted token to a chip as an INDEPENDENT transaction (undo #1 → text, undo #2 → pre-paste). */ + | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert } + /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */ + | { readonly type: 'invalidate-paste' } + | { readonly type: 'enter'; readonly mode: 'queue' | 'steer' } + | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } + | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } + | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } + /** + * An ordinary (default-sink) send was accepted: clear the draft as a COMMIT — + * undo must not resurrect sent content (mirrors submit-settled's success arm). + */ + | { readonly type: 'send-committed' } + | { readonly type: 'release' } + +/** + * InputMachine output effects (executed by the SessionInput shell; the + * machine stays pure). Draft/occurrence mutations carry no effect — the + * shell publishes the state store after every dispatch. + */ +export type InputEffect = + | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } + | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } + | { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' } + | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts new file mode 100644 index 0000000000..25ebab9e32 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -0,0 +1,105 @@ +/** + * Draft decoration pure core (design §9.1: chips render from the occurrence + * table at placeholder offsets; the claim token renders as a mirror-layer + * highlight, the claim hint as ghost text). Zero React — the skeleton renders + * the instructions; tests drive this directly. + */ +import type { InputState } from './contract.ts' + +/** The claim-token highlight range (always draft-leading while the watch holds). */ +export interface TokenRange { + readonly start: number + readonly end: number +} + +/** One chip render instruction: the placeholder at `offset` draws as `label`. */ +export interface ChipRender { + /** Stable render key (same-labeled chips stay independent). */ + readonly occurrenceId: number + /** Placeholder offset in the draft (the chip occupies [offset, offset+1)). */ + readonly offset: number + readonly label: string + /** Owner-resolution failure styling bit. */ + readonly invalid: boolean +} + +/** + * One plain-text reference range (decision 21): a `/name` or `@name` token + * whose name is on the trigger's lexicon. Pure derivation — editing the text + * out of match shape simply drops the range next scan. + */ +export interface TextRefRange { + readonly start: number + readonly end: number + readonly trigger: '/' | '@' +} + +/** Decoration product: claim token range + chip instructions + text-ref ranges + the ghost hint. */ +export interface DraftDecorations { + /** Claim token range while claimed/submitting and the prefix watch holds; null otherwise. */ + readonly token: TokenRange | null + /** Chip render instructions in draft order (occurrence table is offset-sorted). */ + readonly chips: readonly ChipRender[] + /** Scan-derived plain-text reference ranges (empty without a lexicon). */ + readonly textRefs: readonly TextRefRange[] + /** Ghost hint shown while the claim's args are blank; null otherwise. */ + readonly hint: string | null +} + +/** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ +const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g + +/** + * Scan the draft for plain-text reference tokens against the hot lexicons + * (decision 21). Word-boundary discipline: the trigger must sit at the draft + * start or after whitespace ('x/name' never matches); the name must be an + * exact lexicon member. + * @param draft - draft text. + * @param lexicon - per-trigger name lists (a missing trigger scans nothing). + * @returns matched ranges in draft order. + */ +export function scanTextRefs( + draft: string, lexicon: ReadonlyMap<'/' | '@', readonly string[]>, +): TextRefRange[] { + if (lexicon.size === 0 || draft === '') return [] + const out: TextRefRange[] = [] + TEXT_REF_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = TEXT_REF_RE.exec(draft)) !== null) { + const trigger = m[2] as '/' | '@' + const name = m[3] ?? '' + if (lexicon.get(trigger)?.includes(name)) { + const start = m.index + (m[1]?.length ?? 0) + out.push({ start, end: start + 1 + name.length, trigger }) + } + } + return out +} + +/** The empty lexicon (default: zero text-ref decorations, old call sites unchanged). */ +const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() + +/** + * Derive the mirror-layer decorations from the input state. + * @param state - published input state. + * @param lexicon - optional per-trigger reference lexicons (decision 21 scan). + * @returns token range, chip instructions, text-ref ranges, and the ghost hint. + */ +export function deriveDecorations( + state: InputState, lexicon: ReadonlyMap<'/' | '@', readonly string[]> = EMPTY_LEXICON, +): DraftDecorations { + const { draft, claim, phase, occurrences } = state + const claimActive = (phase === 'claimed' || phase === 'submitting') + && claim !== undefined && draft.startsWith(claim.token) + const token: TokenRange | null = claimActive ? { start: 0, end: claim.token.length } : null + const chips = occurrences.map(o => ({ + occurrenceId: o.occurrenceId, + offset: o.offset, + label: o.label, + invalid: o.invalid === true, + })) + const hint = claimActive && claim.hint !== undefined && draft.slice(claim.token.length).trim() === '' + ? claim.hint + : null + return { token, chips, textRefs: scanTextRefs(draft, lexicon), hint } +} diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts new file mode 100644 index 0000000000..f3f6dd7451 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -0,0 +1,446 @@ +/** + * SessionInput shell over the pure input machine: the sole machine caller + * and effect executor. Owns the InputState store (machine state + the queue + * overlay), the notice channel, and the submit transaction plumbing + * (adjudicate via the session's SlashController; claim.submit; default + * sink). Package-private; the hub alone constructs it and wires the scoped + * event listeners onto it. + */ +import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, + ReferenceInsert, SlashController, TokenSpan, +} from '@deepseek-ai/dsh-client-ui-slash/client' +import type { + EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, + PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, +} from './contract.ts' +import { InputMachine } from './machine.ts' + +/** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */ +export interface PopupDismissFace { + dismiss(): void +} + +/** + * Construction seams of one facade. The slash/popup faces are THUNKS: the + * shell is created inside the sessions provide materialization (before the + * scope record is queryable), where `slash.sessionOf`/`command.popupFor` + * cannot resolve yet — resolution defers to first interactive use. + */ +export interface SessionInputDeps { + /** Session-scope ctx handed to claim.submit transactions. */ + actx: ClientContext + /** Enter adjudication face resolver; absent/undefined answer = every '/' line falls to the default sink. */ + slash?: (() => SlashController | undefined) | undefined + /** PopupSelect shell face resolver (dismissal on submit lock / escape). */ + popup?: (() => PopupDismissFace | undefined) | undefined + /** Queue read face; overlaid onto InputState.queue (absent = empty). */ + queue?: ObservableSnapshot | undefined + /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ + defaultSink(text: string, mode: 'queue' | 'steer'): void +} + +/** Guard tier from the machine phase. */ +function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' { + switch (phase) { + case 'plain': return 'plain' + case 'claimed': return 'claimed' + default: return 'frozen' // adjudicating / submitting + } +} + +const EMPTY_QUEUE: readonly QueuedMessage[] = [] + +/** No-pipeline lexicon: zero text-ref decorations. */ +const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() + +/** + * The per-session input facade: scoped-event application verbs + + * setDraft/submit + the published InputState store. + */ +export class SessionInputShell implements SessionInput { + /** Published machine state + queue overlay (the InputZone currency source). */ + readonly state: SnapshotStore + /** Latest surfaced notice (null after clear); the wiring renders it beside the error strip. */ + readonly notices: SnapshotStore = createSnapshotStore(null) + /** The public provide-channel action face (one stable identity per session — decision 20). */ + readonly actions: InputActions = { + setDraft: (text) => { this.setDraft(text) }, + submit: (mode) => { this.submit(mode) }, + } + + // Real wall clock: the typing-run merge window must actually expire in + // production (the machine's no-clock default is a constant for pure tests). + private readonly core = new InputMachine({ now: () => Date.now() }) + private noticeSeq = 0 + private lastDraft = '' + private disposed = false + /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ + private mirrorFn: ((text: string) => void) | undefined + + constructor(private readonly deps: SessionInputDeps) { + this.state = createSnapshotStore(this.compose()) + deps.queue?.subscribe(() => { this.publish() }) + } + + // ---- SessionInput face ---- + + /** + * Single draft write path (all mutation rides machine events). + * @param text - the full next draft. + * @param editRange - the DOM-observed edit shape, when the caller knows it + * (narrows the machine's occurrence math; absent → diff scan). + */ + setDraft(text: string, editRange?: EditRange): void { + this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) })) + } + + /** + * Clear the draft as a successful-send commit: no undo unit is recorded and + * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content + * (the command path gets the same discipline from submit-settled success). + */ + commitSend(): void { + this.run(this.core.dispatch({ type: 'send-committed' })) + } + + /** + * Insert a newline at the selection as one machine transaction (the + * execCommand path is gone — a second undo history would fork). + * @param selection - current DOM selection in draft coordinates. + */ + newline(selection: EditSelection): void { + this.run(this.core.dispatch({ type: 'newline', selection })) + } + + /** Undo the latest transaction (InputBar intercepts the platform chord). */ + undo(): void { + this.run(this.core.dispatch({ type: 'undo' })) + } + + /** Redo the latest undone transaction. */ + redo(): void { + this.run(this.core.dispatch({ type: 'redo' })) + } + + /** + * Paste text over the selection in one transaction, with any hot-snapshot + * sync matches componentized inside it. + * @param text - pasted plain text. + * @param selection - replaced selection in draft coordinates. + * @param components - sync-matched reference components (disjoint, inside `text`). + * @param generation - projection generation for late async-upgrade guards. + */ + pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void { + this.run(this.core.dispatch({ + type: 'paste-begin', text, selection, + ...(components !== undefined ? { components } : {}), + ...(generation !== undefined ? { generation } : {}), + })) + } + + /** End the live paste-match attempt (caret/selection ops and Slash updates the machine cannot see). */ + invalidatePaste(): void { + this.run(this.core.dispatch({ type: 'invalidate-paste' })) + } + + /** + * Enter adjudication + submit transaction + default sink. Effects fan out + * from the machine; this method only feeds the event. Lock entry + * (adjudicating/submitting) force-closes the transient layers: the popup + * dismisses and the menu tracks frozen. + * @param mode - default-sink mode (queue appends; steer interrupts). + */ + submit(mode: 'queue' | 'steer' = 'queue'): void { + this.run(this.core.dispatch({ type: 'enter', mode })) + const phase = this.snapshot.phase + if (phase === 'adjudicating' || phase === 'submitting') { + this.deps.popup?.()?.dismiss() + this.deps.slash?.()?.track(this.snapshot.draft, 0, { tier: 'frozen' }, this.snapshot.draftRev) + } + } + + /** + * Feed a draft/caret change through trigger detection (guard derived from + * the machine phase). + * @param draft - live draft text. + * @param caret - caret position in draft coordinates. + */ + track(draft: string, caret: number): void { + this.deps.slash?.()?.track(draft, caret, { tier: guardOf(this.snapshot.phase) }, this.snapshot.draftRev) + } + + /** + * Keyboard arbitration while the menu is open. + * @param key - the intercepted key. + * @param composing - IME composition guard state. + * @returns the menu's verdict; 'pass' when no pipeline is mounted. + */ + arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome { + return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass' + } + + /** + * Space adjudication over the controller's hot state. + * @returns true = a claim/insert was applied — the caller preventDefaults. + */ + space(): boolean { + const slash = this.deps.slash?.() + if (slash === undefined) return false + const consumed = slash.onSpace() + // Machine-driven draft replacement never passes through onChange, so + // re-track: the caret lands after the token, where detection sees + // whitespace and closes the menu. + if (consumed) { + const next = this.snapshot + slash.track(next.draft, next.draft.length, { tier: guardOf(next.phase) }, next.draftRev) + } + return consumed + } + + /** Dismiss the popupSelect shell (any interaction outside the box). */ + dismissPopup(): void { + this.deps.popup?.()?.dismiss() + } + + /** + * Hot plain-text reference lexicons for the decoration scan (decision 21). + * @returns the controller's per-trigger aggregation; empty Map without a pipeline. + */ + lexicon(): ReadonlyMap<'/' | '@', readonly string[]> { + return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON + } + + /** + * Apply one command claim (scoped begin-command event listener body). + * @param claim - the command claim from the pick path. + * @param span - pick-time span snapshot. + * @returns whether the machine accepted (phase + span CAS passed and the draft mutated). + */ + beginCommand(claim: CommandClaim, span: TokenSpan): boolean { + const before = this.core.state.draftRev + this.run(this.core.dispatch({ type: 'begin-command', claim, span })) + return this.core.state.phase === 'claimed' && this.core.state.draftRev !== before + } + + /** + * Apply one reference insertion (scoped insert-reference event listener body). + * @param ref - the reference insertion from the pick path. + * @param span - pick-time span snapshot. + * @returns whether the machine accepted. + */ + insertReference(ref: ReferenceInsert, span: TokenSpan): boolean { + const before = this.core.state.draftRev + this.run(this.core.dispatch({ type: 'insert-ref', reference: ref, span })) + return this.core.state.draftRev !== before + } + + /** + * Consume one command token after business success (scoped consume-token + * event listener body). Span guard: revision CAS then splice; bare-token + * guard: trimmed-draft equality then clear. + * @param guard - exact span or bare-token guard. + * @returns whether the token was consumed. + */ + consumeToken(guard: ConsumeTokenRequest['guard']): boolean { + const snapshot = this.core.state + if (guard.kind === 'span') { + if (guard.span.draftRev !== snapshot.draftRev) return false + const draft = snapshot.draft + this.setDraft(draft.slice(0, guard.span.start) + draft.slice(guard.span.end)) + return true + } + if (snapshot.draft.trim() !== guard.token) return false + this.setDraft('') + return true + } + + /** + * Insert plain reference text over the pick-time span (scoped insert-text + * event listener body, decision 21). Same CAS-then-splice shape as the + * consume-token span branch: the machine sees an ordinary draft-changed + * transaction (one undo step), no occurrence is minted — the chip look is + * a scan-derived decoration, never state. + * @param text - the plain reference text to splice in (e.g. `/name `). + * @param span - pick-time span snapshot (draftRev CAS). + * @returns whether the text was applied. + */ + insertText(text: string, span: TokenSpan): boolean { + const snapshot = this.core.state + if (span.draftRev !== snapshot.draftRev) return false + const draft = snapshot.draft + this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end)) + return true + } + + /** + * Surface a notice from outside the machine (detached command results). + * @param level - severity tier. + * @param text - notice body. + */ + notify(level: 'info' | 'error', text: string): void { + this.noticeSeq += 1 + this.notices.set({ level, text, seq: this.noticeSeq }) + } + + // ---- wiring-layer extras (not on the frozen SessionInput face) ---- + + /** Teardown: abort any in-flight attempt and stop accepting async settlements. */ + dispose(): void { + this.disposed = true + this.run(this.core.dispatch({ type: 'release' })) + } + + /** Read the live machine state (guard derivation reads here). */ + get snapshot(): InputState { + return this.state.getSnapshot() + } + + /** + * Bind the draft persistence mirror (chat store write). Adopt-on-bind: the + * store draft may hold a persisted value from a previous mount; the caller + * seeds it via setDraft BEFORE binding, and afterwards every machine-adopted + * draft mirrors out. + * @param write - store draft write. + * @returns the unbind disposer. + */ + bindMirror(write: (text: string) => void): () => void { + this.mirrorFn = write + return () => { + if (this.mirrorFn === write) this.mirrorFn = undefined + } + } + + // ---- effect executor ---- + + private run(effects: readonly InputEffect[]): void { + for (const fx of effects) this.execute(fx) + this.publish() + } + + private execute(fx: InputEffect): void { + switch (fx.type) { + case 'notice': { + this.noticeSeq += 1 + this.notices.set({ level: fx.level, text: fx.text, seq: this.noticeSeq }) + return + } + case 'adjudicate': { + this.adjudicate(fx.attempt, fx.draft) + return + } + case 'begin-submit': { + this.beginSubmit(fx.attempt, fx.claim, fx.args) + return + } + case 'default-sink': { + this.sinkSerialized(fx.draft, fx.mode) + return + } + default: + return // machine-internal effects (mirror rides publish) + } + } + + /** + * Prompt serialization before the sink (design §3.12): expand each + * placeholder to its owner's model form via the session controller's + * codec routing. Owner missing / serialize failure / disposal blocks the + * send — notice + draft and chips retained, never a silent downgrade to + * the clipboard text. Chip-free drafts skip the async detour. + */ + private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void { + const occurrences = this.core.state.occurrences + if (occurrences.length === 0) { + this.deps.defaultSink(draft.trim(), mode) + return + } + const slash = this.deps.slash?.() + const controller = new AbortController() + void Promise.all(occurrences.map(async (o) => { + if (slash === undefined) throw new Error(`no serializer for reference source "${o.source}"`) + return { offset: o.offset, text: await slash.serializeReference(o.source, o.ref, controller.signal) } + })).then( + (parts) => { + if (this.disposed) return + // Splice model forms over their placeholders (offsets are draft-time; + // parts arrive offset-sorted since the table is). + let out = '' + let cursor = 0 + for (const part of parts) { + out += draft.slice(cursor, part.offset) + part.text + cursor = part.offset + 1 + } + out += draft.slice(cursor) + this.deps.defaultSink(out.trim(), mode) + }, + (error: unknown) => { + controller.abort() + if (this.disposed) return + const message = error instanceof Error ? error.message : String(error) + this.notify('error', message) + }, + ) + } + + /** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */ + private adjudicate(attempt: SubmitAttempt, draft: string): void { + const slash = this.deps.slash?.() + if (slash === undefined) { + // No pipeline mounted: the '/' line is an ordinary message. + this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) + return + } + slash.adjudicate(draft.trim(), attempt.signal).then( + (outcome: PickOutcome) => { + if (this.dead(attempt)) return + this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome })) + }, + (error: unknown) => { + if (this.dead(attempt)) return + const message = error instanceof Error ? error.message : String(error) + this.run(this.core.dispatch({ type: 'adjudication-failed', attempt, message })) + }, + ) + } + + /** The submit transaction: claim.submit against the session scope; ok maps from the outcome kind. */ + private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void { + Promise.resolve() + .then(() => claim.submit(args, this.deps.actx)) + .then( + (outcome) => { + if (this.dead(attempt)) return + this.run(this.core.dispatch({ + type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome, + })) + }, + (error: unknown) => { + if (this.dead(attempt)) return + const message = error instanceof Error ? error.message : String(error) + this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) + }, + ) + } + + /** Late-settlement guard: superseded attempts and disposed facades drop silently. */ + private dead(attempt: SubmitAttempt): boolean { + return this.disposed || attempt.signal.aborted + } + + private compose(): InputState { + const core = this.core.state + return { ...core, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE } + } + + private publish(): void { + const next = this.compose() + this.state.set(next) + if (next.draft !== this.lastDraft) { + this.lastDraft = next.draft + this.mirrorFn?.(next.draft) + } + } +} diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts new file mode 100644 index 0000000000..2ae474be31 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -0,0 +1,146 @@ +/** + * InputHub: the InputService implementation (`ctx.conversation.input`) — one + * SessionInputShell per session, created inside the sessions provide + * materialization (decision 19: the 'input' standard-kit entry IS the + * creation trigger) and torn down by the scope disposer (instance-and-scope + * share one lifecycle). The hub registers the three scoped input-mutation + * listeners on each session's actx (the sole consumer side of the ui-slash + * bail events) and owns the default-sink choreography: every session is a + * real host entity, so the sink is one unconditional prompt path. + */ +import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client' +import type {} from '@deepseek-ai/dsh-client-ui-slash/client' +import { queueReadFaceOf } from '../queue/store.ts' +import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' +import type { PopupDismissFace } from './facade.ts' +import { SessionInputShell } from './facade.ts' + +/** Structural command face for per-session popup resolution. */ +interface CommandFace { + popupFor(actx: ClientContext): PopupDismissFace +} + +/** Session-addressed input facade registry (InputService face + composer-layer extras). */ +export class InputHub implements InputService { + private readonly shells = new Map() + + /** @param ctx - client root context (services resolved lazily per call — boot order stays free). */ + constructor(private readonly rootCtx: ClientContext) {} + + /** + * Resolve the facade for one session-scope ctx (InputService face). + * @param actx - session-scope context. + * @returns the resident per-session facade. + */ + for(actx: ClientContext): SessionInput { + const sessions = this.sessions() + const id = sessions.scopeOf(actx) + if (id === undefined) throw new Error('conversation.input.for requires a session scope') + return this.shell(id) + } + + /** + * Resident shell for one session binding — the provide-channel entry + * (called during scope materialization, BEFORE the scope record is + * queryable, hence binding-fed and hence the thunked slash/popup deps). + * Wires the scoped event listeners + teardown into the session scope. + * @param binding - session assembly handle. + * @returns the shell. + */ + shellFor(binding: SessionBinding): SessionInputShell { + const existing = this.shells.get(binding.sessionId) + if (existing !== undefined) return existing + const { sessionId: id, session, ctx: actx } = binding + const shell = new SessionInputShell({ + actx, + slash: () => this.controller(actx), + popup: () => this.popup(actx), + queue: queueReadFaceOf(session), + defaultSink: (text, mode) => { this.sink(session, text, mode) }, + }) + this.shells.set(id, shell) + // The one teardown axis: listeners, shell, and map entries all ride the + // scope fiber (decision 12 — nothing here outlives the scope). + actx.effect(() => { + const offs = [ + actx.on('slash/input-begin-command', req => + shell.beginCommand(req.claim, req.span) ? true : undefined), + actx.on('slash/input-insert-reference', req => + shell.insertReference(req.reference, req.span) ? true : undefined), + actx.on('slash/input-consume-token', req => + shell.consumeToken(req.guard) ? true : undefined), + actx.on('slash/input-insert-text', req => + shell.insertText(req.text, req.span) ? true : undefined), + ] + return () => { + for (const off of offs) off() + shell.dispose() + this.shells.delete(id) + } + }, 'conversation.input: session shell') + return shell + } + + /** + * Resident shell by session id (service-face path; the provide channel has + * normally created it already — this covers direct id-addressed access). + * @param id - session id. + * @returns the shell. + */ + shell(id: SessionId): SessionInputShell { + const existing = this.shells.get(id) + if (existing !== undefined) return existing + const binding = this.sessions().binding(id) + if (binding === undefined) throw new Error(`conversation.input: session "${id}" resolved no binding`) + return this.shellFor(binding) + } + + /** + * The InputBar-exclusive keyboard command face (decision 20): the shell + * satisfies it structurally; package-internal — handed through the + * composer-bar entry's inject, never across a plugin boundary. + * @param id - session id. + * @returns the shell as the keyboard face. + */ + keyboard(id: SessionId): ComposerKeyboard { + return this.shell(id) + } + + /** + * Default sink: optimistic clear + prompt. The session is always a real + * host entity (materialized when its workspace was picked), so there is + * exactly one path; a failed first prompt is an ordinary prompt failure + * (error strip via promptError, draft restored only while untouched). + */ + private sink(session: Session, text: string, mode: 'queue' | 'steer'): void { + if (text === '') return + const shell = this.shells.get(session.sessionId) + // Commit, not an editable clear: undo must not resurrect sent content. + shell?.commitSend() + void session.prompt([{ type: 'text', text }], mode).then( + (result) => { + if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text) + }, + () => { + if (shell?.snapshot.draft === '') shell.setDraft(text) + }, + ) + } + + private controller(actx: ClientContext): SlashController | undefined { + const slash = this.rootCtx.get('slash') as SlashServiceContract | undefined + return slash?.sessionOf(actx) + } + + private popup(actx: ClientContext): PopupDismissFace | undefined { + const command = this.rootCtx.get('command') as CommandFace | undefined + return command?.popupFor(actx) + } + + private sessions(): SessionsService { + const sessions = this.rootCtx.get('sessions') + if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable') + return sessions + } +} diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts new file mode 100644 index 0000000000..f9c5a479a4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -0,0 +1,570 @@ +/** + * InputMachine: the pure per-session input state machine (design §9.1, eng. + * plan §3.9-3.12). Events in, effects out; zero React / DOM / cordis / ambient + * clock. Package-private — the SessionInput shell is the only caller and the + * sole executor of the returned effects. + * + * Draft truth: the draft string holds one U+FFFC placeholder per chip; the + * occurrence table carries identity and the owner's cached projections. Every + * draft mutation is one transaction — draft edit, occurrence reconciliation, + * and undo-log push are atomic inside dispatch() — and bumps draftRev, which + * is what lets span CAS reduce to a revision-equality check: equal rev ⟹ + * identical draft ⟹ identical span content. Callers observe mutation success + * as a draftRev advance (begin-command / insert-ref / consume-token / + * paste-upgrade all answer their bail events this way). + */ +import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { + ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions, + InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt, +} from './contract.ts' + +/** The object-replacement character backing every chip occurrence in the draft. */ +export const PLACEHOLDER = '' + +/** The machine never writes the queue; the wiring layer overlays the T9 store projection. */ +const EMPTY_QUEUE: InputState['queue'] = [] + +/** Undo ring depth (design §9.1: bounded self-managed transaction log). */ +const LOG_LIMIT = 100 + +/** Exhaustiveness backstop for the closed InputEvent / guard unions. */ +function unreachable(value: never): never { + throw new Error(`unreachable input event: ${JSON.stringify(value)}`) +} + +/** + * Strip the claim token off a draft to yield submit args. Leading whitespace + * (incl. newlines — leading-trigger trim) is tolerated; a bare `/name` + * missing the token's trailing separator yields empty args. Exactly one + * separator char is consumed; the remainder — newlines included — stays + * verbatim (`/goal x\ny` → `x\ny`). + */ +function argsAfter(draft: string, token: string): string { + const s = draft.trimStart() + if (s.startsWith(token)) return s.slice(token.length) + const base = token.trimEnd() + if (s.startsWith(base)) { + const rest = s.slice(base.length) + return /^\s/.test(rest) ? rest.slice(1) : rest + } + return '' +} + +/** + * Prefix/suffix common-scan recovering the edit range between two drafts + * (used when the wiring layer cannot supply one from the DOM event). + */ +function diffEdit(prev: string, next: string): EditRange { + let p = 0 + const maxCommon = Math.min(prev.length, next.length) + while (p < maxCommon && prev[p] === next[p]) p += 1 + let s = 0 + const maxSuffix = maxCommon - p + while (s < maxSuffix && prev[prev.length - 1 - s] === next[next.length - 1 - s]) s += 1 + return { start: p, end: prev.length - s, insertedLength: next.length - s - p } +} + +/** + * Expand the draft's placeholders into their occurrences' clipboard text + * (decision 16: the persistence mirror and clipboard both write this + * projection — U+FFFC never leaves the machine). Table order is offset + * order, so one linear walk pairs placeholders with entries. + * @param state - published input state. + * @returns the plain-text projection of the draft. + */ +export function projectClipboard(state: Pick): string { + const { draft, occurrences } = state + if (occurrences.length === 0) return draft + let out = '' + let cursor = 0 + for (const o of occurrences) { + out += draft.slice(cursor, o.offset) + o.clipboardText + cursor = o.offset + 1 + } + return out + draft.slice(cursor) +} + +/** One undo unit: snapshots taken before the transaction applied. */ +interface Transaction { + readonly draftBefore: string + readonly occurrencesBefore: readonly Occurrence[] + /** Pre-edit selection when the triggering event carried one (shell caret restore on undo). */ + readonly selectionBefore?: EditSelection +} + +/** + * Pure input machine, one instance per session (per-session isolation is by + * construction). The machine constructs one AbortController per SubmitAttempt + * at enter time and aborts it itself on release; the shell never aborts, it + * only observes attempt.signal on its adjudicate/submit promises. Stale + * attempts (any adjudicated / adjudication-failed / submit-settled whose seq + * is not the in-flight one) are dropped: same state, zero effects. + */ +export class InputMachine { + private draft = '' + private draftRev = 0 + private phase: InputState['phase'] = 'plain' + private claim: CommandClaim | undefined + private occurrences: readonly Occurrence[] = [] + private occurrenceSeq = 0 + private seq = 0 + private inflight: { + readonly attempt: SubmitAttempt + readonly controller: AbortController + readonly mode: 'queue' | 'steer' + } | undefined + private log: Transaction[] = [] + private redoStack: Transaction[] = [] + /** Open single-char typing run: the next contiguous char within the window coalesces. */ + private typingRun: { readonly end: number; readonly at: number } | undefined + private paste: PasteAttemptState | undefined + private pasteSeq = 0 + private readonly mergeWindowMs: number + private readonly now: () => number + + constructor(options: InputMachineOptions = {}) { + this.mergeWindowMs = options.mergeWindowMs ?? 1000 + this.now = options.now ?? (() => 0) + } + + /** Read-only snapshot of the machine state (queue always empty at this tier). */ + get state(): InputState { + const c = this.claim + return { + draft: this.draft, + draftRev: this.draftRev, + phase: this.phase, + ...(c ? { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}) } } : {}), + occurrences: this.occurrences, + ...(this.paste !== undefined ? { paste: this.paste } : {}), + queue: EMPTY_QUEUE, + } + } + + /** + * Feed one event through the machine. + * @param ev - Input event; the single write path for all input state. + * @returns Effects for the shell to execute in order; empty on no-ops, locks, and dropped stale events. + */ + dispatch(ev: InputEvent): readonly InputEffect[] { + switch (ev.type) { + case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange) + case 'newline': return this.onNewline(ev.selection) + case 'begin-command': return this.onBeginCommand(ev.claim, ev.span) + case 'insert-ref': return this.onInsertRef(ev.reference, ev.span) + case 'consume-token': return this.onConsumeToken(ev.guard) + case 'set-invalid': return this.onSetInvalid(ev.invalidIds) + case 'undo': return this.onUndo() + case 'redo': return this.onRedo() + case 'paste-begin': return this.onPasteBegin(ev.text, ev.selection, ev.components, ev.generation) + case 'paste-upgrade': return this.onPasteUpgrade(ev.attemptId, ev.span, ev.reference) + case 'invalidate-paste': { + this.paste = undefined + return [] + } + case 'enter': return this.onEnter(ev.mode) + case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) + case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) + case 'submit-settled': return this.onSubmitSettled(ev) + case 'send-committed': return this.onSendCommitted() + case 'release': return this.onRelease() + default: return unreachable(ev) + } + } + + // ---- transaction plumbing ---- + + /** Adopt a new draft: bump the revision (the span-CAS invalidation point). */ + private adopt(draft: string): void { + this.draft = draft + this.draftRev += 1 + } + + /** Push one undo unit (before-state), trim the ring, and cut the redo chain. */ + private pushTxn(selectionBefore?: EditSelection): void { + this.log.push({ + draftBefore: this.draft, + occurrencesBefore: this.occurrences, + ...(selectionBefore !== undefined ? { selectionBefore } : {}), + }) + if (this.log.length > LOG_LIMIT) this.log.shift() + this.redoStack = [] + } + + /** + * Reconcile the occurrence table with one edit (old-draft coordinates): + * entries past the range shift by the length delta; entries whose + * placeholder sits inside the replaced range go away whole (design §9.1: a + * deletion/replacement intersecting a placeholder acts on the whole chip). + */ + private reconcile(range: EditRange): void { + const delta = range.insertedLength - (range.end - range.start) + const kept: Occurrence[] = [] + for (const o of this.occurrences) { + if (o.offset < range.start) kept.push(o) + else if (o.offset >= range.end) kept.push(delta === 0 ? o : { ...o, offset: o.offset + delta }) + } + this.occurrences = kept + } + + /** Claimed integrity watch: any mutation that breaks the token prefix releases the claim. */ + private watchClaim(): void { + if (this.phase === 'claimed' && this.claim !== undefined && !this.draft.startsWith(this.claim.token)) { + this.phase = 'plain' + this.claim = undefined + } + } + + /** Mint one occurrence at a draft offset. */ + private mint(reference: ReferenceInsert, offset: number): Occurrence { + this.occurrenceSeq += 1 + return { + occurrenceId: this.occurrenceSeq, + source: reference.source, + ref: reference.ref, + offset, + label: reference.label, + clipboardText: reference.clipboardText, + } + } + + /** Splice minted entries into the offset-sorted table. */ + private withMinted(minted: readonly Occurrence[]): void { + if (minted.length === 0) return + this.occurrences = [...this.occurrences, ...minted].sort((a, b) => a.offset - b.offset) + } + + // ---- draft transactions ---- + + private onDraftChanged(draft: string, editRange?: EditRange): InputEffect[] { + if (draft === this.draft) return [] + const range = editRange ?? diffEdit(this.draft, draft) + // Single-char typing coalesces into the open run while contiguous and + // inside the merge window; anything else opens its own transaction. + const typing = range.start === range.end && range.insertedLength === 1 + const at = this.now() + const run = this.typingRun + const merges = typing && run !== undefined && run.end === range.start && at - run.at <= this.mergeWindowMs + if (!merges) this.pushTxn({ start: range.start, end: range.end }) + this.typingRun = typing ? { end: range.start + 1, at } : undefined + this.reconcile(range) + this.adopt(draft) + this.watchClaim() + this.paste = undefined + return [] + } + + /** F1: caret newline as an ordinary machine transaction (execCommand path removed). */ + private onNewline(selection: EditSelection): InputEffect[] { + const { start, end } = selection + if (start < 0 || start > end || end > this.draft.length) return [] + this.pushTxn(selection) + this.typingRun = undefined + this.reconcile({ start, end, insertedLength: 1 }) + this.adopt(this.draft.slice(0, start) + '\n' + this.draft.slice(end)) + this.watchClaim() + this.paste = undefined + return [] + } + + /** Span CAS: revision equality (content identity follows) plus bounds sanity. */ + private casOk(span: TokenSpan): boolean { + return span.draftRev === this.draftRev + && span.start >= 0 && span.start <= span.end && span.end <= this.draft.length + } + + private onBeginCommand(claim: CommandClaim, span: TokenSpan): InputEffect[] { + if (this.phase !== 'plain' && this.phase !== 'claimed') return [] + // Leading-trigger contract: only whitespace may precede the span; the + // whitespace prefix is dropped so the claimed watch (startsWith) holds. + if (!this.casOk(span) || this.draft.slice(0, span.start).trim() !== '') return [] + this.pushTxn() + this.typingRun = undefined + this.reconcile({ start: 0, end: span.end, insertedLength: claim.token.length }) + this.adopt(claim.token + this.draft.slice(span.end)) + this.claim = claim + this.phase = 'claimed' + this.paste = undefined + return [] + } + + private onInsertRef(reference: ReferenceInsert, span: TokenSpan): InputEffect[] { + if (this.phase !== 'plain' && this.phase !== 'claimed') return [] + if (!this.casOk(span)) return [] + this.replaceSpanWithChip(reference, span) + this.paste = undefined + return [] + } + + /** Shared chip-insertion transaction: replace [span) with one placeholder occurrence (insert-ref and paste-upgrade both land here). */ + private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): void { + this.pushTxn() + this.typingRun = undefined + this.reconcile({ start: span.start, end: span.end, insertedLength: 1 }) + this.withMinted([this.mint(reference, span.start)]) + this.adopt(this.draft.slice(0, span.start) + PLACEHOLDER + this.draft.slice(span.end)) + this.watchClaim() + } + + /** + * Guarded token deletion after business success (popup settle / menu-pick + * execute). No effect signals success: the caller reads the draftRev + * advance off the published state (same currency as the other bail verbs). + */ + private onConsumeToken(guard: ConsumeTokenGuard): InputEffect[] { + if (this.phase !== 'plain' && this.phase !== 'claimed') return [] + switch (guard.kind) { + case 'span': { + const span = guard.span + if (!this.casOk(span) || span.start === span.end) return [] + this.pushTxn() + this.typingRun = undefined + this.reconcile({ start: span.start, end: span.end, insertedLength: 0 }) + this.adopt(this.draft.slice(0, span.start) + this.draft.slice(span.end)) + this.watchClaim() + this.paste = undefined + return [] + } + case 'bare-token': { + if (guard.token === '' || this.draft.trim() !== guard.token) return [] + this.pushTxn() + this.typingRun = undefined + this.occurrences = [] + this.adopt('') + this.watchClaim() + this.paste = undefined + return [] + } + default: return unreachable(guard) + } + } + + /** + * Owner-resolution style bits: exactly the listed occurrences render + * invalid. Not a transaction — the draft, revision, and undo log are + * untouched (design §9.1: invalidation never deletes or rewrites chips). + */ + private onSetInvalid(invalidIds: readonly number[]): InputEffect[] { + const ids = new Set(invalidIds) + if (!this.occurrences.some(o => (o.invalid === true) !== ids.has(o.occurrenceId))) return [] + this.occurrences = this.occurrences.map((o) => { + const invalid = ids.has(o.occurrenceId) + if ((o.invalid === true) === invalid) return o + const { invalid: _drop, ...rest } = o + return invalid ? { ...rest, invalid: true } : rest + }) + return [] + } + + // ---- undo / redo ---- + + private onUndo(): InputEffect[] { + const entry = this.log.pop() + if (entry === undefined) return [] + this.redoStack.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences }) + this.occurrences = entry.occurrencesBefore + this.adopt(entry.draftBefore) + this.watchClaim() + this.typingRun = undefined + this.paste = undefined + return [] + } + + private onRedo(): InputEffect[] { + const entry = this.redoStack.pop() + if (entry === undefined) return [] + // Manual log push: pushTxn would cut the redo chain being walked. + this.log.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences }) + if (this.log.length > LOG_LIMIT) this.log.shift() + this.occurrences = entry.occurrencesBefore + this.adopt(entry.draftBefore) + this.watchClaim() + this.typingRun = undefined + this.paste = undefined + return [] + } + + // ---- paste plane ---- + + /** + * Paste as one transaction: the text (U+FFFC-sanitized) replaces the + * selection; hot-snapshot sync matches componentize inside the SAME + * transaction (one undo returns to pre-paste); a match attempt opens for + * the async remainder while the phase still accepts reference mutations. + */ + private onPasteBegin( + rawText: string, selection: EditSelection, + components: readonly PasteComponent[] = [], generation = 0, + ): InputEffect[] { + const { start, end } = selection + if (start < 0 || start > end || end > this.draft.length) return [] + const text = rawText.split(PLACEHOLDER).join('') + this.pushTxn(selection) + this.typingRun = undefined + // Componentize: replace each matched token range (paste-text coordinates, + // disjoint by contract) with a placeholder while assembling the insert. + const sorted = [...components].sort((a, b) => a.start - b.start) + const minted: Occurrence[] = [] + let inserted = '' + let cursor = 0 + for (const c of sorted) { + inserted += text.slice(cursor, c.start) + minted.push(this.mint(c.reference, start + inserted.length)) + inserted += PLACEHOLDER + cursor = c.end + } + inserted += text.slice(cursor) + this.reconcile({ start, end, insertedLength: inserted.length }) + this.withMinted(minted) + this.adopt(this.draft.slice(0, start) + inserted + this.draft.slice(end)) + this.watchClaim() + if (this.phase === 'plain' || this.phase === 'claimed') { + this.pasteSeq += 1 + this.paste = { + attemptId: this.pasteSeq, + insertedRange: { start, end: start + inserted.length }, + generation, + } + } else { + this.paste = undefined + } + return [] + } + + /** + * Async match landed: upgrade one pasted token to a chip as an INDEPENDENT + * transaction (undo #1 → the token text, undo #2 → pre-paste). The attempt + * stays current — later tokens re-CAS against the advanced draftRev. + */ + private onPasteUpgrade(attemptId: number, span: TokenSpan, reference: ReferenceInsert): InputEffect[] { + const attempt = this.paste + if (attempt === undefined || attempt.attemptId !== attemptId) return [] + if (this.phase !== 'plain' && this.phase !== 'claimed') return [] + if (!this.casOk(span) || span.start === span.end) return [] + this.replaceSpanWithChip(reference, span) + this.paste = { + ...attempt, + insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + 1 - (span.end - span.start) }, + } + return [] + } + + // ---- submit plane ---- + + /** Mint the next SubmitAttempt and take the in-flight slot. */ + private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt { + const controller = new AbortController() + this.seq += 1 + const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft } + this.inflight = { attempt, controller, mode } + return attempt + } + + private onEnter(mode: 'queue' | 'steer'): InputEffect[] { + if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] + if (this.phase === 'claimed' && this.claim !== undefined) { + const attempt = this.beginAttempt(mode) + this.phase = 'submitting' + this.paste = undefined + return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }] + } + const trimmed = this.draft.trim() + if (trimmed === '') return [] + this.paste = undefined + if (trimmed.startsWith('/')) { + const attempt = this.beginAttempt(mode) + this.phase = 'adjudicating' + return [{ type: 'adjudicate', attempt, draft: this.draft }] + } + return [{ type: 'default-sink', draft: this.draft, mode }] + } + + private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { + const flight = this.inflight + if (this.phase !== 'adjudicating' || flight === undefined || flight.attempt.seq !== attempt.seq) return [] + if (outcome !== undefined && outcome !== 'handled' && 'claim' in outcome) { + this.claim = outcome.claim + this.phase = 'submitting' + return [{ + type: 'begin-submit', + attempt, + claim: outcome.claim, + args: argsAfter(attempt.draftSnapshot, outcome.claim.token), + }] + } + // 'handled' (source dealt internally), {insert} (no enter-time span + // semantics), or a miss: all land plain; only the miss flows to the sink. + this.inflight = undefined + this.phase = 'plain' + return outcome === undefined + ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }] + : [] + } + + private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] { + if (this.phase !== 'adjudicating' || this.inflight?.attempt.seq !== attempt.seq) return [] + this.inflight = undefined + this.phase = 'plain' + // Draft retained: warmup failure never silently downgrades to a prompt. + return [{ type: 'notice', level: 'error', text: message }] + } + + private onSubmitSettled(ev: Extract): InputEffect[] { + const flight = this.inflight + if (this.phase !== 'submitting' || flight === undefined || flight.attempt.seq !== ev.attempt.seq) return [] + this.inflight = undefined + if (ev.ok) { + this.phase = 'plain' + this.claim = undefined + this.occurrences = [] + this.adopt('') + // Committed content is gone for good: undo must not resurrect a sent draft. + this.log = [] + this.redoStack = [] + this.typingRun = undefined + this.paste = undefined + return ev.outcome?.text !== undefined + ? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }] + : [] + } + const text = ev.message ?? ev.outcome?.text ?? 'command failed' + // Drift guard: keep the enter-time draft (same claim) only while the + // live draft still equals it; user input typed during flight wins. + // Claimed re-entry additionally requires the watch to hold — an + // enter-path snapshot may carry leading whitespace the token never had. + if (this.draft === flight.attempt.draftSnapshot + && this.claim !== undefined && this.draft.startsWith(this.claim.token)) { + this.phase = 'claimed' + return [{ type: 'notice', level: 'error', text }] + } + this.phase = 'plain' + this.claim = undefined + return [{ type: 'notice', level: 'error', text }] + } + + /** Ordinary send accepted: clear as a commit (no undo unit; sent content + * must not be resurrectable — same discipline as submit-settled success). */ + private onSendCommitted(): InputEffect[] { + this.claim = undefined + this.occurrences = [] + this.adopt('') + this.log = [] + this.redoStack = [] + this.typingRun = undefined + this.paste = undefined + return [] + } + + private onRelease(): InputEffect[] { + if (this.inflight !== undefined) { + this.inflight.controller.abort() + this.inflight = undefined + } + this.phase = 'plain' + this.claim = undefined + this.typingRun = undefined + this.paste = undefined + return [] + } +} diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css new file mode 100644 index 0000000000..adc0c42b48 --- /dev/null +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -0,0 +1,30 @@ +/* Neutral stacked strip above the input (queue rows are informational, not a warn state). */ + +.dock { + margin: 6px 0; + padding: 8px 12px; + border: 1px solid var(--dsw-alias-separator-primary); + border-radius: 10px; + background: var(--dsw-alias-bg-base); +} + +.title { + font-size: 12px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); +} + +.list { + margin: 4px 0 0; + padding: 0; + list-style: none; +} + +.row { + overflow: hidden; + font-size: 12px; + line-height: 20px; + color: var(--dsw-alias-label-primary); + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx new file mode 100644 index 0000000000..f5b5047c7b --- /dev/null +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -0,0 +1,48 @@ +// Read-only queue dock entry (design v4 queue cut 1): renders the session's +// inbox mirror (session/queued frames + connect baseline) as one stacked +// strip above the input. No per-row actions — the host inbox has no +// addressable entries yet (queue cut 2 ledger). +// +// The 'conversation.input.dock' SlotMap declaration lives in +// ../contract/slots.ts beside the other input-region slots. +import type { Context } from 'cordis' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-runtime/client' +import css from './QueueDock.module.css' + +/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ +export type QueueDockProps = PropsRuntime<'conversation.input.dock'> + +/** Queue strip: one preview line per queued message; renders null when the queue is empty. */ +export function QueueDock({ useSession }: QueueDockProps) { + const queue = useSession(s => s.queue) + if (queue.length === 0) return null + return ( +
    +
    已排队 {queue.length} 条
    +
      + {queue.map(row => ( +
    • {row.preview}
    • + ))} +
    +
    + ) +} + +/** + * The dock entry as a plain registrant plugin (bash posture). + * `inject: ['conversation']` is the ordering seam: the conversation service + * mounts after ui-conversation's slot registrations, so the + * 'conversation.input.dock' declaration is on the ledger by then. + */ +export const queueDockEntry = { + name: 'conversation-queue-dock', + inject: ['slots', 'conversation'], + /** + * Register the queue strip into the input dock (list entry, order 0). + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', order: 0 }, QueueDock) + }, +} diff --git a/packages/client/ui-conversation/src/client/queue/store.ts b/packages/client/ui-conversation/src/client/queue/store.ts new file mode 100644 index 0000000000..5d113b750d --- /dev/null +++ b/packages/client/ui-conversation/src/client/queue/store.ts @@ -0,0 +1,24 @@ +/** + * Queue read face for the InputState.queue projection (frozen contract in + * ../input/contract.ts): a uSES-compatible observable over one session's + * queue rows. The Session snapshot already keeps the queue array + * reference-stable across unrelated snapshot swaps, so this is a pure + * projection — no second store, no copy. + */ +import type { ObservableSnapshot, Session } from '@deepseek-ai/dsh-client-runtime/client' +import type { QueuedMessage } from '../input/contract.ts' + +/** + * Project a session's queue rows as a bare observable (subscribe/getSnapshot). + * The wiring layer (T5) overlays this onto InputState.queue; the runtime + * QueuedMessage and the input-contract QueuedMessage are structurally the + * same frozen shape ({key, preview}). + * @param session - the resident session instance. + * @returns the queue read face (snapshot reference stable while the queue is unchanged). + */ +export function queueReadFaceOf(session: Session): ObservableSnapshot { + return { + getSnapshot: () => session.getSnapshot().queue, + subscribe: fn => session.subscribe(fn), + } +} diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 5ea5ea96ee..0d2d8e9d8e 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -1,5 +1,5 @@ /** - * Scope-addressed conversation send, cancel, history, and retained-prompt orchestration. + * Scope-addressed conversation send, cancel, and history orchestration. * * Scope addressing rides the cordis Service tracker: property access through * `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods @@ -13,15 +13,23 @@ import type { Context } from 'cordis' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { InputHub } from './input/hub.ts' /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ export class ConversationService extends Service { + /** The per-session input machine registry (InputService face, design §5.2). */ + readonly input: InputHub + /** * @param ctx - owning root context (the plugin apply context; the service * registers itself and follows that fiber's lifetime). + * @param config - the shared InputHub constructed by the plugin apply + * (shared with the slot inject factories); absent = own instance + * (object-layer tests that never touch slots). */ - constructor(ctx: Context) { + constructor(ctx: Context, config?: { input?: InputHub }) { super(ctx, 'conversation') + this.input = config?.input ?? new InputHub(ctx) } /** @@ -49,19 +57,6 @@ export class ConversationService extends Service { await this.scopedSession('loadOlder').loadOlder() } - /** - * Update the scoped Session's retained pending prompt. - * @param text - exact controlled-input value to retain. - */ - updatePendingPrompt(text: string): void { - this.scopedSession('updatePendingPrompt').updatePendingPrompt(text) - } - - /** Retry the scoped Session's retained pending prompt. */ - retryPendingPrompt(): void { - this.scopedSession('retryPendingPrompt').retryPendingPrompt() - } - /** Resolve the caller scope's Session or throw on root contexts. */ private scopedSession(op: string): Session { const id = this.scopeId(op) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 3523919f51..be68ea1394 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -127,3 +127,23 @@ flex-direction: column; min-height: 0; } + +/* Composer stack: dock strips above the input card (design §6 MIX order). */ +.composerStack { + display: flex; + flex-direction: column; +} + +/* Hero phase: the composer stack (hero chrome + workspace row + card) is + flex-centered in the column; composer phase docks it at the bottom. Flex, + NOT absolute+transform: a transform would make this box the containing + block for position:fixed descendants (pickers/modals), shrinking them. */ +.composerHero { + align-self: center; + width: min(776px, calc(100% - 48px)); + z-index: 1; +} + +.root[data-phase='hero'] { + justify-content: center; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index a87f6e4aa9..5f9e91a049 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -1,182 +1,92 @@ -// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 + -// Tab_Group + view area + composer). Pure component — everything arrives via -// props: the framework standard kit (useSession/sessionId/useSessions), the -// declared chat store's useStore/actions, the injected business face, and the -// renderSlot share for the declared 'conversation.view' child slot (views are -// slot entries; the active one renders via the list `only` filter) plus the -// renderSlotChain share for the 'conversation.composer' takeover chain. -// Breadcrumbs derive from useSessions with a pure parentId walk; the active -// view id lives in the chat store's `view` field (per-session by store scope). +// Resident conversation skeleton. Hero chrome, composer positioning, and the +// chain stay mounted across no-session/session transitions. Only the inert +// input body swaps for the strict session InputBar. -import { useSyncExternalStore } from 'react' +import { useRef, useState } from 'react' import clsx from 'clsx' -import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConversationSlotProps } from '../contract/slots.ts' -import { InputBar } from './InputBar.tsx' -import type { InputBarError } from './InputBar.tsx' -import { EmptyHero, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' +import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' +import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' +import { DisabledInputBar } from './DisabledInputBar.tsx' import css from './ConversationRoot.module.css' -/** Full props = the automatic shares & injected share — composed by reference - * from the contract, never re-typed here (share-ownership rule). */ +/** Full props composed from the slot contract. */ export type ConversationRootProps = ConversationSlotProps -/** Breadcrumb chain: walk parentId links (root ancestor first, self last; - * empty when unknown; a broken link stops the walk). Pure twin of the - * sessions service's ancestry — components derive, they don't subscribe. */ -function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] { - const chain: SessionSummary[] = [] - let cursor: SessionId | undefined = id - while (cursor !== undefined) { - const summary: SessionSummary | undefined = list.byId[cursor] - if (summary === undefined || chain.includes(summary)) break - chain.unshift(summary) - cursor = summary.parentId - } - return chain -} - export function ConversationRoot({ - sessionId, useSession, useSessions, useWorkspaces, useStore, actions, renderSlot, renderSlotChain, - views, send, stop, open, updateSessionPrompt, retrySessionPrompt, + sessionId, useSession, useSessions, useWorkspaces, useInput, + renderSlot, renderSlotChain, selectWorkspace, }: ConversationRootProps) { - useSyncExternalStore(views.subscribe, views.version) - const tabs = views.list() - // The store's persisted view id may be stale (view plugin unloaded); the - // slot ledger is the runtime validator — unknown ids fall to the first view. - const activeId = useStore(s => s.view) ?? 'chat' - const active = tabs.find(v => v.id === activeId) ?? tabs[0] - - const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) - const pendingPrompt = useSession(s => s.pendingPrompt ?? undefined) - const storedDraft = useStore(s => s.draft) - const draft = pendingPrompt?.text ?? storedDraft - const sessionRunning = useSession(s => s.running) - const running = sessionRunning || pendingPrompt?.phase === 'sending' - const removed = useSession(s => s.removed) - const promptError = useSession(s => s.promptError) - const turns = useSession(s => countTurns(s)) - const pending = useSession(s => s.pending) const openState = useSession(s => s.openState) const composerPhase = useSession(s => s.composerPhase) - const cwd = useSessions(s => s.byId[sessionId]?.cwd) - const workspaceTitle = useWorkspaces(state => - state.items.find(workspace => workspace.sessionIds.includes(sessionId))?.title) - const error: InputBarError | null = pendingPrompt?.error !== undefined - ? { - op: pendingPrompt.retry === 'connect' ? 'session' : 'send', - message: pendingPrompt.retry === 'connect' - ? `Workspace attach failed: ${pendingPrompt.error}` - : `Message send failed: ${pendingPrompt.error}`, - } - : promptError === null - ? null - : { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` } - const status = pendingPrompt?.phase === 'sending' - ? pendingPrompt.retry === 'connect' ? 'Attaching session to workspace…' : 'Sending message…' - : undefined - const setDraft = (text: string): void => { - if (pendingPrompt === undefined) actions.setDraft(text) - else updateSessionPrompt(text) - } - const submit = (mode: 'queue' | 'steer'): void => { - if (pendingPrompt === undefined) send(draft, mode) - else retrySessionPrompt() - } + const pending = useSession(s => s.pending) ?? [] + const session = useSession(s => s) + const inputState = useInput(s => s) + const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd) + const workspaces = useWorkspaces(s => s) - // Blank-session guidance: phase-derived (the runtime snapshot owns the - // predicate — see ComposerPhase). Only `blank` renders the hero; `engaging` - // and `active` fall through to the conversation view, so an in-flight - // first send never bounces back here. Gated on the OPEN window: phase has - // no jurisdiction over loading/error frames (ChatView renders those). - if (openState === 'open' && composerPhase === 'blank') { - return ( - } - draft={draft} - disabled={removed || pendingPrompt?.phase === 'sending'} - error={error} - {...(status === undefined ? {} : { status })} - onDraftChange={setDraft} - onSend={submit} + const [pickerOpen, setPickerOpen] = useState(false) + const pickerAnchor = useRef(null) + + const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) + const zone: InputZone | undefined = + session === undefined || inputState === undefined ? undefined : { session, input: inputState } + + const heroWorkspaceRow = ( + <> + w.sessionIds.includes(sessionId))?.title ?? workspaceLabel(cwd ?? '') + } + menuOpen={pickerOpen} + onClick={() => { setPickerOpen(open => !open) }} /> - ) - } + {renderSlot('conversation.hero.workspace', { + open: pickerOpen, + anchorRef: pickerAnchor, + onPick: (workspaceId) => { + setPickerOpen(false) + selectWorkspace(workspaceId) + }, + onClose: () => { setPickerOpen(false) }, + })} + + ) + + const inputBar = sessionId === undefined + ? + : renderSlot('conversation.composer.bar', { + variant: hero ? 'hero' : 'composer', + ...(hero ? { placeholder: 'Describe what you want to build' } : {}), + overlay: renderSlot('conversation.input.overlay', {}), + leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), + rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), + }) - // The default composer doubles as the chain's all-decline fallback: a - // pending wait with no registered takeover must still leave the input usable. const composerBar = ( - +
    + {hero && } + {hero && heroWorkspaceRow} + {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} + {!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)} + {inputBar} +
    ) return ( -
    -
    -
    - - {/* Header button row (Fork / Session log / I/O Details): a P-I visual - placeholder registry slot is deferred — buttons land with their features. */} -
    - {tabs.length > 1 && ( -
    - {tabs.map(v => ( - - ))} -
    - )} -
    - -
    - {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} -
    - - {renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })} +
    + {/* Mounted for every real session, hero included: ConversationSession + renders no chrome while blank but owns the draft-persistence mirror + bind — unmounting it in the hero would lose pre-first-send text on + a refresh or scope rebuild. */} + {sessionId !== undefined && renderSlot('conversation.session', {})} + {renderSlotChain( + 'conversation.composer', + { interactions: pending }, + { fallback: composerBar, overlay: true }, + )}
    ) } - -/** Turn count = user message nodes in the window (display meta; exact host count deferred). */ -function countTurns(s: { nodes: readonly { kind: string }[] }): number { - let n = 0 - for (const node of s.nodes) if (node.kind === 'user') n += 1 - return n -} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx new file mode 100644 index 0000000000..515bfe1f93 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -0,0 +1,103 @@ +/** Strict per-session conversation content: header, view ring, and chat store bindings. */ + +import { useEffect, useSyncExternalStore } from 'react' +import clsx from 'clsx' +import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationSessionSlotProps } from '../contract/slots.ts' +import css from './ConversationRoot.module.css' + +/** Full props composed from the strict session slot contract. */ +export type ConversationSessionProps = ConversationSessionSlotProps + +function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] { + const chain: SessionSummary[] = [] + let cursor: SessionId | undefined = id + while (cursor !== undefined) { + const summary: SessionSummary | undefined = list.byId[cursor] + if (summary === undefined || chain.includes(summary)) break + chain.unshift(summary) + cursor = summary.parentId + } + return chain +} + +export function ConversationSession({ + sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, + renderSlot, views, bindDraftMirror, open, +}: ConversationSessionProps) { + useSyncExternalStore(views.subscribe, views.version) + const tabs = views.list() + const activeId = useStore(s => s.view) ?? 'chat' + const active = tabs.find(view => view.id === activeId) ?? tabs[0] + const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) + const turns = useSession(s => countTurns(s)) + const composerPhase = useSession(s => s.composerPhase) + const blank = useSession(s => s.blank) + const inputState = useInput(s => s) + const storedDraft = useStore(s => s.draft) + + useEffect(() => { + if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft) + const unmirror = bindDraftMirror(actions.setDraft) + return () => { unmirror() } + // Mount-only: later store writes come from the machine mirror. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputActions]) + + if (blank && composerPhase === 'blank') return null + + return ( + <> +
    +
    + +
    + {tabs.length > 1 && ( +
    + {tabs.map(view => ( + + ))} +
    + )} +
    +
    + {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} +
    + + ) +} + +function countTurns(snapshot: { nodes: readonly { kind: string }[] }): number { + let count = 0 + for (const node of snapshot.nodes) if (node.kind === 'user') count += 1 + return count +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx new file mode 100644 index 0000000000..118baf5ac9 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx @@ -0,0 +1,40 @@ +/** Inert no-session input body; the resident Hero shell renders around it. */ + +import clsx from 'clsx' +import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './InputBar.module.css' + +/** Disabled visual twin of the session-bound InputBar. */ +export function DisabledInputBar() { + return ( +
    +
    +
    +

    See today’s specials.